From a1baa247a4da3df4448b0536d7e3b9ae6e407156 Mon Sep 17 00:00:00 2001 From: Chris Yeoh Date: Wed, 24 Jul 2013 12:51:22 +0930 Subject: [PATCH] Adds API version discovery support for V3 Adds version information for the V3 API which is only displayed when the V3 API is enabled. Even if the the V3 API is enabled the V3 API status is "EXPERIMENTAL" and the V2 one "CURRENT". This was done so autodiscovery tools would not yet use the V3 version by default. Ports the relevant parts of the version extension and associated tests to the V3 API to display V3 version information for /v3 GET requests. DocImpact Partially implements blueprint nova-v3-api Change-Id: Idd335ce0df63d91e94a4a757f1fbae94b576c37e --- doc/api_samples/versions-get-resp.json | 13 +- doc/api_samples/versions-get-resp.xml | 5 +- .../openstack/compute/plugins/v3/versions.py | 57 ++++ nova/api/openstack/compute/versions.py | 39 +++ nova/api/openstack/compute/views/versions.py | 12 +- .../compute/plugins/v3/test_versions.py | 246 ++++++++++++++++++ .../api/openstack/compute/test_versions.py | 128 ++++++++- .../api_samples/versions-get-resp.json.tpl | 13 +- .../api_samples/versions-get-resp.xml.tpl | 3 + setup.cfg | 1 + 10 files changed, 503 insertions(+), 14 deletions(-) create mode 100644 nova/api/openstack/compute/plugins/v3/versions.py create mode 100644 nova/tests/api/openstack/compute/plugins/v3/test_versions.py diff --git a/doc/api_samples/versions-get-resp.json b/doc/api_samples/versions-get-resp.json index 8bcc7f4f2075..40c7c4add158 100644 --- a/doc/api_samples/versions-get-resp.json +++ b/doc/api_samples/versions-get-resp.json @@ -10,6 +10,17 @@ ], "status": "CURRENT", "updated": "2011-01-21T11:33:21Z" + }, + { + "id": "v3.0", + "links": [ + { + "href": "http://openstack.example.com/v3/", + "rel": "self" + } + ], + "status": "EXPERIMENTAL", + "updated": "2013-07-23T11:33:21Z" } ] -} \ No newline at end of file +} diff --git a/doc/api_samples/versions-get-resp.xml b/doc/api_samples/versions-get-resp.xml index d0cea0cd55de..cf53b0dd2c07 100644 --- a/doc/api_samples/versions-get-resp.xml +++ b/doc/api_samples/versions-get-resp.xml @@ -3,4 +3,7 @@ - \ No newline at end of file + + + + diff --git a/nova/api/openstack/compute/plugins/v3/versions.py b/nova/api/openstack/compute/plugins/v3/versions.py new file mode 100644 index 000000000000..8e8f5d71b63e --- /dev/null +++ b/nova/api/openstack/compute/plugins/v3/versions.py @@ -0,0 +1,57 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2013 IBM Corp. +# 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 nova.api.openstack.compute import versions +from nova.api.openstack.compute.views import versions as views_versions +from nova.api.openstack import extensions +from nova.api.openstack import wsgi + + +ALIAS = "versions" + + +class VersionsController(object): + @extensions.expected_errors(()) + @wsgi.serializers(xml=versions.VersionTemplate, + atom=versions.VersionAtomSerializer) + def show(self, req): + builder = views_versions.get_view_builder(req) + return builder.build_version(versions.VERSIONS['v3.0']) + + +class Versions(extensions.V3APIExtensionBase): + """API Version information.""" + + name = "Versions" + alias = ALIAS + namespace = "http://docs.openstack.org/compute/core/versions/v3" + version = 1 + + def get_resources(self): + resources = [ + extensions.ResourceExtension(ALIAS, VersionsController(), + custom_routes_fn=self.version_map)] + return resources + + def get_controller_extensions(self): + return [] + + def version_map(self, mapper, wsgi_resource): + mapper.connect("versions", "/", + controller=wsgi_resource, + action='show', conditions={"method": ['GET']}) + mapper.redirect("", "/") diff --git a/nova/api/openstack/compute/versions.py b/nova/api/openstack/compute/versions.py index 0d2117053834..b93287bb6878 100644 --- a/nova/api/openstack/compute/versions.py +++ b/nova/api/openstack/compute/versions.py @@ -16,6 +16,7 @@ # under the License. from lxml import etree +from oslo.config import cfg from nova.api.openstack.compute.views import versions as views_versions from nova.api.openstack import wsgi @@ -23,6 +24,9 @@ from nova.api.openstack import xmlutil from nova.openstack.common import timeutils +CONF = cfg.CONF +CONF.import_opt('enabled', 'nova.api.openstack', group='osapi_v3') + LINKS = { 'v2.0': { 'pdf': 'http://docs.openstack.org/' @@ -30,6 +34,12 @@ LINKS = { 'wadl': 'http://docs.openstack.org/' 'api/openstack-compute/2/wadl/os-compute-2.wadl' }, + 'v3.0': { + 'pdf': 'http://docs.openstack.org/' + 'api/openstack-compute/3/os-compute-devguide-3.pdf', + 'wadl': 'http://docs.openstack.org/' + 'api/openstack-compute/3/wadl/os-compute-3.wadl' + }, } @@ -60,6 +70,33 @@ VERSIONS = { "type": "application/vnd.openstack.compute+json;version=2", } ], + }, + "v3.0": { + "id": "v3.0", + "status": "EXPERIMENTAL", + "updated": "2013-07-23T11:33:21Z", + "links": [ + { + "rel": "describedby", + "type": "application/pdf", + "href": LINKS['v3.0']['pdf'], + }, + { + "rel": "describedby", + "type": "application/vnd.sun.wadl+xml", + "href": LINKS['v3.0']['wadl'], + }, + ], + "media-types": [ + { + "base": "application/xml", + "type": "application/vnd.openstack.compute+xml;version=3", + }, + { + "base": "application/json", + "type": "application/vnd.openstack.compute+json;version=3", + } + ], } } @@ -205,6 +242,8 @@ class VersionAtomSerializer(AtomSerializer): class Versions(wsgi.Resource): def __init__(self): super(Versions, self).__init__(None) + if not CONF.osapi_v3.enabled: + del VERSIONS["v3.0"] @wsgi.serializers(xml=VersionsTemplate, atom=VersionsAtomSerializer) diff --git a/nova/api/openstack/compute/views/versions.py b/nova/api/openstack/compute/views/versions.py index 7e8d15f0bbd0..336008c7ac56 100644 --- a/nova/api/openstack/compute/views/versions.py +++ b/nova/api/openstack/compute/views/versions.py @@ -44,7 +44,7 @@ class ViewBuilder(common.ViewBuilder): "links": [ { "rel": "self", - "href": self.generate_href(req.path), + "href": self.generate_href(version['id'], req.path), }, ], "media-types": version['media-types'], @@ -75,7 +75,7 @@ class ViewBuilder(common.ViewBuilder): def _build_links(self, version_data): """Generate a container of links that refer to the provided version.""" - href = self.generate_href() + href = self.generate_href(version_data['id']) links = [ { @@ -86,10 +86,14 @@ class ViewBuilder(common.ViewBuilder): return links - def generate_href(self, path=None): + def generate_href(self, version, path=None): """Create an url that refers to a specific version_number.""" prefix = self._update_compute_link_prefix(self.base_url) - version_number = 'v2' + if version.find('v3.') == 0: + version_number = 'v3' + else: + version_number = 'v2' + if path: path = path.strip('/') return os.path.join(prefix, version_number, path) diff --git a/nova/tests/api/openstack/compute/plugins/v3/test_versions.py b/nova/tests/api/openstack/compute/plugins/v3/test_versions.py new file mode 100644 index 000000000000..c0ec034b2931 --- /dev/null +++ b/nova/tests/api/openstack/compute/plugins/v3/test_versions.py @@ -0,0 +1,246 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2013 IBM Corp. +# Copyright 2010-2011 OpenStack Foundation +# +# 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 feedparser +from lxml import etree +import webob + +from nova.api.openstack import xmlutil +from nova.openstack.common import jsonutils +from nova import test +from nova.tests.api.openstack import common +from nova.tests.api.openstack import fakes + + +NS = { + 'atom': 'http://www.w3.org/2005/Atom', + 'ns': 'http://docs.openstack.org/common/api/v1.0' +} + + +EXP_LINKS = { + 'v3.0': { + 'pdf': 'http://docs.openstack.org/' + 'api/openstack-compute/3/os-compute-devguide-3.pdf', + 'wadl': 'http://docs.openstack.org/' + 'api/openstack-compute/3/wadl/os-compute-3.wadl' + }, +} + + +EXP_VERSIONS = { + "v3.0": { + "id": "v3.0", + "status": "EXPERIMENTAL", + "updated": "2013-07-23T11:33:21Z", + "links": [ + { + "rel": "describedby", + "type": "application/pdf", + "href": EXP_LINKS['v3.0']['pdf'], + }, + { + "rel": "describedby", + "type": "application/vnd.sun.wadl+xml", + "href": EXP_LINKS['v3.0']['wadl'], + }, + ], + "media-types": [ + { + "base": "application/xml", + "type": "application/vnd.openstack.compute+xml;version=3", + }, + { + "base": "application/json", + "type": "application/vnd.openstack.compute+json;version=3", + } + ], + } +} + + +class VersionsTest(test.TestCase): + + def test_get_version_list_302(self): + req = webob.Request.blank('/v3') + req.accept = "application/json" + res = req.get_response(fakes.wsgi_app_v3()) + self.assertEqual(res.status_int, 302) + redirect_req = webob.Request.blank('/v3/') + self.assertEqual(res.location, redirect_req.url) + + def test_get_version_3_detail(self): + req = webob.Request.blank('/v3/') + req.accept = "application/json" + res = req.get_response(fakes.wsgi_app_v3()) + self.assertEqual(res.status_int, 200) + self.assertEqual(res.content_type, "application/json") + version = jsonutils.loads(res.body) + expected = { + "version": { + "id": "v3.0", + "status": "EXPERIMENTAL", + "updated": "2013-07-23T11:33:21Z", + "links": [ + { + "rel": "self", + "href": "http://localhost/v3/", + }, + ], + "links": [ + { + "rel": "self", + "href": "http://localhost/v3/", + }, + { + "rel": "describedby", + "type": "application/pdf", + "href": EXP_LINKS['v3.0']['pdf'], + }, + { + "rel": "describedby", + "type": "application/vnd.sun.wadl+xml", + "href": EXP_LINKS['v3.0']['wadl'], + }, + ], + "media-types": [ + { + "base": "application/xml", + "type": "application/" + "vnd.openstack.compute+xml;version=3", + }, + { + "base": "application/json", + "type": "application/" + "vnd.openstack.compute+json;version=3", + }, + ], + }, + } + self.assertEqual(expected, version) + + def test_get_version_3_detail_content_type(self): + req = webob.Request.blank('/') + req.accept = "application/json;version=3" + res = req.get_response(fakes.wsgi_app_v3()) + self.assertEqual(res.status_int, 200) + self.assertEqual(res.content_type, "application/json") + version = jsonutils.loads(res.body) + expected = { + "version": { + "id": "v3.0", + "status": "EXPERIMENTAL", + "updated": "2013-07-23T11:33:21Z", + "links": [ + { + "rel": "self", + "href": "http://localhost/v3/", + }, + ], + "links": [ + { + "rel": "self", + "href": "http://localhost/v3/", + }, + { + "rel": "describedby", + "type": "application/pdf", + "href": EXP_LINKS['v3.0']['pdf'], + }, + { + "rel": "describedby", + "type": "application/vnd.sun.wadl+xml", + "href": EXP_LINKS['v3.0']['wadl'], + }, + ], + "media-types": [ + { + "base": "application/xml", + "type": "application/" + "vnd.openstack.compute+xml;version=3", + }, + { + "base": "application/json", + "type": "application/" + "vnd.openstack.compute+json;version=3", + }, + ], + }, + } + self.assertEqual(expected, version) + + def test_get_version_3_detail_xml(self): + req = webob.Request.blank('/v3/') + req.accept = "application/xml" + res = req.get_response(fakes.wsgi_app_v3()) + self.assertEqual(res.status_int, 200) + self.assertEqual(res.content_type, "application/xml") + + version = etree.XML(res.body) + xmlutil.validate_schema(version, 'version') + + expected = EXP_VERSIONS['v3.0'] + self.assertTrue(version.xpath('/ns:version', namespaces=NS)) + media_types = version.xpath('ns:media-types/ns:media-type', + namespaces=NS) + self.assertTrue(common.compare_media_types(media_types, + expected['media-types'])) + for key in ['id', 'status', 'updated']: + self.assertEqual(version.get(key), expected[key]) + links = version.xpath('atom:link', namespaces=NS) + self.assertTrue(common.compare_links(links, + [{'rel': 'self', 'href': 'http://localhost/v3/'}] + + expected['links'])) + + def test_get_version_3_detail_atom(self): + req = webob.Request.blank('/v3/') + req.accept = "application/atom+xml" + res = req.get_response(fakes.wsgi_app_v3()) + self.assertEqual(res.status_int, 200) + self.assertEqual("application/atom+xml", res.content_type) + + xmlutil.validate_schema(etree.XML(res.body), 'atom') + + f = feedparser.parse(res.body) + self.assertEqual(f.feed.title, 'About This Version') + self.assertEqual(f.feed.updated, '2013-07-23T11:33:21Z') + self.assertEqual(f.feed.id, 'http://localhost/v3/') + self.assertEqual(f.feed.author, 'Rackspace') + self.assertEqual(f.feed.author_detail.href, + 'http://www.rackspace.com/') + self.assertEqual(f.feed.links[0]['href'], 'http://localhost/v3/') + self.assertEqual(f.feed.links[0]['rel'], 'self') + + self.assertEqual(len(f.entries), 1) + entry = f.entries[0] + self.assertEqual(entry.id, 'http://localhost/v3/') + self.assertEqual(entry.title, 'Version v3.0') + self.assertEqual(entry.updated, '2013-07-23T11:33:21Z') + self.assertEqual(len(entry.content), 1) + self.assertEqual(entry.content[0].value, + 'Version v3.0 EXPERIMENTAL (2013-07-23T11:33:21Z)') + self.assertEqual(len(entry.links), 3) + self.assertEqual(entry.links[0]['href'], 'http://localhost/v3/') + self.assertEqual(entry.links[0]['rel'], 'self') + self.assertEqual(entry.links[1], { + 'href': EXP_LINKS['v3.0']['pdf'], + 'type': 'application/pdf', + 'rel': 'describedby'}) + self.assertEqual(entry.links[2], { + 'href': EXP_LINKS['v3.0']['wadl'], + 'type': 'application/vnd.sun.wadl+xml', + 'rel': 'describedby'}) diff --git a/nova/tests/api/openstack/compute/test_versions.py b/nova/tests/api/openstack/compute/test_versions.py index 973fd0105f22..66699aa8e118 100644 --- a/nova/tests/api/openstack/compute/test_versions.py +++ b/nova/tests/api/openstack/compute/test_versions.py @@ -75,6 +75,21 @@ EXP_VERSIONS = { }, ], }, + "v3.0": { + "id": "v3.0", + "status": "EXPERIMENTAL", + "updated": "2013-07-23T11:33:21Z", + "media-types": [ + { + "base": "application/xml", + "type": "application/vnd.openstack.compute+xml;version=3", + }, + { + "base": "application/json", + "type": "application/vnd.openstack.compute+json;version=3", + } + ], + } } @@ -98,6 +113,16 @@ class VersionsTest(test.TestCase): "href": "http://localhost/v2/", }], }, + { + "id": "v3.0", + "status": "EXPERIMENTAL", + "updated": "2013-07-23T11:33:21Z", + "links": [ + { + "rel": "self", + "href": "http://localhost/v3/", + }], + }, ] self.assertEqual(versions, expected) @@ -232,9 +257,9 @@ class VersionsTest(test.TestCase): self.assertTrue(root.xpath('/ns:versions', namespaces=NS)) versions = root.xpath('ns:version', namespaces=NS) - self.assertEqual(len(versions), 1) + self.assertEqual(len(versions), 2) - for i, v in enumerate(['v2.0']): + for i, v in enumerate(['v2.0', 'v3.0']): version = versions[i] expected = EXP_VERSIONS[v] for key in ['id', 'status', 'updated']: @@ -291,7 +316,7 @@ class VersionsTest(test.TestCase): f = feedparser.parse(res.body) self.assertEqual(f.feed.title, 'Available API Versions') - self.assertEqual(f.feed.updated, '2011-01-21T11:33:21Z') + self.assertEqual(f.feed.updated, '2013-07-23T11:33:21Z') self.assertEqual(f.feed.id, 'http://localhost/') self.assertEqual(f.feed.author, 'Rackspace') self.assertEqual(f.feed.author_detail.href, @@ -299,7 +324,7 @@ class VersionsTest(test.TestCase): self.assertEqual(f.feed.links[0]['href'], 'http://localhost/') self.assertEqual(f.feed.links[0]['rel'], 'self') - self.assertEqual(len(f.entries), 1) + self.assertEqual(len(f.entries), 2) entry = f.entries[0] self.assertEqual(entry.id, 'http://localhost/v2/') self.assertEqual(entry.title, 'Version v2.0') @@ -311,6 +336,17 @@ class VersionsTest(test.TestCase): self.assertEqual(entry.links[0]['href'], 'http://localhost/v2/') self.assertEqual(entry.links[0]['rel'], 'self') + entry = f.entries[1] + self.assertEqual(entry.id, 'http://localhost/v3/') + self.assertEqual(entry.title, 'Version v3.0') + self.assertEqual(entry.updated, '2013-07-23T11:33:21Z') + self.assertEqual(len(entry.content), 1) + self.assertEqual(entry.content[0].value, + 'Version v3.0 EXPERIMENTAL (2013-07-23T11:33:21Z)') + self.assertEqual(len(entry.links), 1) + self.assertEqual(entry.links[0]['href'], 'http://localhost/v3/') + self.assertEqual(entry.links[0]['rel'], 'self') + def test_multi_choice_image(self): req = webob.Request.blank('/images/1') req.accept = "application/json" @@ -320,6 +356,28 @@ class VersionsTest(test.TestCase): expected = { "choices": [ + { + "id": "v3.0", + "status": "EXPERIMENTAL", + "links": [ + { + "href": "http://localhost/v3/images/1", + "rel": "self", + }, + ], + "media-types": [ + { + "base": "application/xml", + "type": + "application/vnd.openstack.compute+xml;version=3", + }, + { + "base": "application/json", + "type": + "application/vnd.openstack.compute+json;version=3", + } + ], + }, { "id": "v2.0", "status": "CURRENT", @@ -357,9 +415,9 @@ class VersionsTest(test.TestCase): root = etree.XML(res.body) self.assertTrue(root.xpath('/ns:choices', namespaces=NS)) versions = root.xpath('ns:version', namespaces=NS) - self.assertEqual(len(versions), 1) + self.assertEqual(len(versions), 2) - version = versions[0] + version = versions[1] self.assertEqual(version.get('id'), 'v2.0') self.assertEqual(version.get('status'), 'CURRENT') media_types = version.xpath('ns:media-types/ns:media-type', @@ -373,6 +431,20 @@ class VersionsTest(test.TestCase): self.assertTrue(common.compare_links(links, [{'rel': 'self', 'href': 'http://localhost/v2/images/1'}])) + version = versions[0] + self.assertEqual(version.get('id'), 'v3.0') + self.assertEqual(version.get('status'), 'EXPERIMENTAL') + media_types = version.xpath('ns:media-types/ns:media-type', + namespaces=NS) + self.assertTrue(common. + compare_media_types(media_types, + EXP_VERSIONS['v3.0']['media-types'] + )) + + links = version.xpath('atom:link', namespaces=NS) + self.assertTrue(common.compare_links(links, + [{'rel': 'self', 'href': 'http://localhost/v3/images/1'}])) + def test_multi_choice_server_atom(self): """ Make sure multi choice responses do not have content-type @@ -394,6 +466,28 @@ class VersionsTest(test.TestCase): expected = { "choices": [ + { + "id": "v3.0", + "status": "EXPERIMENTAL", + "links": [ + { + "href": "http://localhost/v3/servers/" + uuid, + "rel": "self", + }, + ], + "media-types": [ + { + "base": "application/xml", + "type": + "application/vnd.openstack.compute+xml;version=3", + }, + { + "base": "application/json", + "type": + "application/vnd.openstack.compute+json;version=3", + } + ], + }, { "id": "v2.0", "status": "CURRENT", @@ -461,7 +555,27 @@ class VersionsViewBuilderTests(test.TestCase): expected = "http://example.org/app/v2/" builder = views.versions.ViewBuilder(base_url) - actual = builder.generate_href() + actual = builder.generate_href('v2') + + self.assertEqual(actual, expected) + + def test_generate_href_v3(self): + base_url = "http://example.org/app/" + + expected = "http://example.org/app/v3/" + + builder = views.versions.ViewBuilder(base_url) + actual = builder.generate_href('v3.0') + + self.assertEqual(actual, expected) + + def test_generate_href_unknown(self): + base_url = "http://example.org/app/" + + expected = "http://example.org/app/v2/" + + builder = views.versions.ViewBuilder(base_url) + actual = builder.generate_href('foo') self.assertEqual(actual, expected) diff --git a/nova/tests/integrated/api_samples/versions-get-resp.json.tpl b/nova/tests/integrated/api_samples/versions-get-resp.json.tpl index b5b20d6b9d28..40c7c4add158 100644 --- a/nova/tests/integrated/api_samples/versions-get-resp.json.tpl +++ b/nova/tests/integrated/api_samples/versions-get-resp.json.tpl @@ -4,12 +4,23 @@ "id": "v2.0", "links": [ { - "href": "%(host)s/v2/", + "href": "http://openstack.example.com/v2/", "rel": "self" } ], "status": "CURRENT", "updated": "2011-01-21T11:33:21Z" + }, + { + "id": "v3.0", + "links": [ + { + "href": "http://openstack.example.com/v3/", + "rel": "self" + } + ], + "status": "EXPERIMENTAL", + "updated": "2013-07-23T11:33:21Z" } ] } diff --git a/nova/tests/integrated/api_samples/versions-get-resp.xml.tpl b/nova/tests/integrated/api_samples/versions-get-resp.xml.tpl index b426f9521521..cf53b0dd2c07 100644 --- a/nova/tests/integrated/api_samples/versions-get-resp.xml.tpl +++ b/nova/tests/integrated/api_samples/versions-get-resp.xml.tpl @@ -3,4 +3,7 @@ + + + diff --git a/setup.cfg b/setup.cfg index df7d33995869..3ebfa9988f32 100644 --- a/setup.cfg +++ b/setup.cfg @@ -106,6 +106,7 @@ nova.api.v3.extensions = services = nova.api.openstack.compute.plugins.v3.services:Services simple_tenant_usage = nova.api.openstack.compute.plugins.v3.simple_tenant_usage:SimpleTenantUsage used_limits = nova.api.openstack.compute.plugins.v3.used_limits:UsedLimits + versions = nova.api.openstack.compute.plugins.v3.versions:Versions nova.api.v3.extensions.server.create = availability_zone = nova.api.openstack.compute.plugins.v3.availability_zone:AvailabilityZone