Add support for header version parameter to specify API version.

bug 844905

The 1.1 API specifies that the API version can be determined by URL path
(eg /v1.1/tenant/servers/detail), Content-Type header
(eg application/json;version=1.1) or Accept header
(eg application/json;q=0.8;version=1.1, application/xml;q=0.2;version=1.1).

Change-Id: I01220cf1eebc0f759d66563ec67ef2f697c6d310
This commit is contained in:
Johannes Erdfelt 2011-09-25 22:51:55 +00:00
parent 3837f09ee0
commit 6e5f2d88e6
8 changed files with 515 additions and 74 deletions

View File

@ -71,7 +71,7 @@ paste.app_factory = nova.api.ec2.metadatarequesthandler:MetadataRequestHandler.f
#############
[composite:osapi]
use = egg:Paste#urlmap
use = call:nova.api.openstack.urlmap:urlmap_factory
/: osversions
/v1.0: openstackapi10
/v1.1: openstackapi11

View File

@ -0,0 +1,297 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# Copyright 2011 OpenStack LLC.
# 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.
import paste.urlmap
import re
import urllib2
from nova import log as logging
from nova.api.openstack import wsgi
_quoted_string_re = r'"[^"\\]*(?:\\.[^"\\]*)*"'
_option_header_piece_re = re.compile(r';\s*([^\s;=]+|%s)\s*'
r'(?:=\s*([^;]+|%s))?\s*' %
(_quoted_string_re, _quoted_string_re))
LOG = logging.getLogger('nova.api.openstack.map')
def unquote_header_value(value):
"""Unquotes a header value.
This does not use the real unquoting but what browsers are actually
using for quoting.
:param value: the header value to unquote.
"""
if value and value[0] == value[-1] == '"':
# this is not the real unquoting, but fixing this so that the
# RFC is met will result in bugs with internet explorer and
# probably some other browsers as well. IE for example is
# uploading files with "C:\foo\bar.txt" as filename
value = value[1:-1]
return value
def parse_list_header(value):
"""Parse lists as described by RFC 2068 Section 2.
In particular, parse comma-separated lists where the elements of
the list may include quoted-strings. A quoted-string could
contain a comma. A non-quoted string could have quotes in the
middle. Quotes are removed automatically after parsing.
The return value is a standard :class:`list`:
>>> parse_list_header('token, "quoted value"')
['token', 'quoted value']
:param value: a string with a list header.
:return: :class:`list`
"""
result = []
for item in urllib2.parse_http_list(value):
if item[:1] == item[-1:] == '"':
item = unquote_header_value(item[1:-1])
result.append(item)
return result
def parse_options_header(value):
"""Parse a ``Content-Type`` like header into a tuple with the content
type and the options:
>>> parse_options_header('Content-Type: text/html; mimetype=text/html')
('Content-Type:', {'mimetype': 'text/html'})
:param value: the header to parse.
:return: (str, options)
"""
def _tokenize(string):
for match in _option_header_piece_re.finditer(string):
key, value = match.groups()
key = unquote_header_value(key)
if value is not None:
value = unquote_header_value(value)
yield key, value
if not value:
return '', {}
parts = _tokenize(';' + value)
name = parts.next()[0]
extra = dict(parts)
return name, extra
class Accept(object):
def __init__(self, value):
self._content_types = [parse_options_header(v) for v in
parse_list_header(value)]
def best_match(self, supported_content_types):
# FIXME: Should we have a more sophisticated matching algorithm that
# takes into account the version as well?
best_quality = -1
best_content_type = None
best_params = {}
best_match = '*/*'
for content_type in supported_content_types:
for content_mask, params in self._content_types:
try:
quality = float(params.get('q', 1))
except ValueError:
continue
if quality < best_quality:
continue
elif best_quality == quality:
if best_match.count('*') <= content_mask.count('*'):
continue
if self._match_mask(content_mask, content_type):
best_quality = quality
best_content_type = content_type
best_params = params
best_match = content_mask
return best_content_type, best_params
def content_type_params(self, best_content_type):
"""Find parameters in Accept header for given content type."""
for content_type, params in self._content_types:
if best_content_type == content_type:
return params
return {}
def _match_mask(self, mask, content_type):
if '*' not in mask:
return content_type == mask
if mask == '*/*':
return True
mask_major = mask[:-2]
content_type_major = content_type.split('/', 1)[0]
return content_type_major == mask_major
def urlmap_factory(loader, global_conf, **local_conf):
if 'not_found_app' in local_conf:
not_found_app = local_conf.pop('not_found_app')
else:
not_found_app = global_conf.get('not_found_app')
if not_found_app:
not_found_app = loader.get_app(not_found_app, global_conf=global_conf)
urlmap = URLMap(not_found_app=not_found_app)
for path, app_name in local_conf.items():
path = paste.urlmap.parse_path_expression(path)
app = loader.get_app(app_name, global_conf=global_conf)
urlmap[path] = app
return urlmap
class URLMap(paste.urlmap.URLMap):
def _match(self, host, port, path_info):
"""Find longest match for a given URL path."""
for (domain, app_url), app in self.applications:
if domain and domain != host and domain != host + ':' + port:
continue
if (path_info == app_url
or path_info.startswith(app_url + '/')):
return app, app_url
return None, None
def _set_script_name(self, app, app_url):
def wrap(environ, start_response):
environ['SCRIPT_NAME'] += app_url
return app(environ, start_response)
return wrap
def _munge_path(self, app, path_info, app_url):
def wrap(environ, start_response):
environ['SCRIPT_NAME'] += app_url
environ['PATH_INFO'] = path_info[len(app_url):]
return app(environ, start_response)
return wrap
def _path_strategy(self, host, port, path_info):
"""Check path suffix for MIME type and path prefix for API version."""
mime_type = app = app_url = None
parts = path_info.rsplit('.', 1)
if len(parts) > 1:
possible_type = 'application/' + parts[1]
if possible_type in wsgi.SUPPORTED_CONTENT_TYPES:
mime_type = possible_type
parts = path_info.split('/')
if len(parts) > 1:
possible_app, possible_app_url = self._match(host, port, path_info)
# Don't use prefix if it ends up matching default
if possible_app and possible_app_url:
app_url = possible_app_url
app = self._munge_path(possible_app, path_info, app_url)
return mime_type, app, app_url
def _content_type_strategy(self, host, port, environ):
"""Check Content-Type header for API version."""
app = None
params = parse_options_header(environ.get('CONTENT_TYPE', ''))[1]
if 'version' in params:
app, app_url = self._match(host, port, '/v' + params['version'])
if app:
app = self._set_script_name(app, app_url)
return app
def _accept_strategy(self, host, port, environ, supported_content_types):
"""Check Accept header for best matching MIME type and API version."""
accept = Accept(environ.get('HTTP_ACCEPT', ''))
app = None
# Find the best match in the Accept header
mime_type, params = accept.best_match(supported_content_types)
if 'version' in params:
app, app_url = self._match(host, port, '/v' + params['version'])
if app:
app = self._set_script_name(app, app_url)
return mime_type, app
def __call__(self, environ, start_response):
host = environ.get('HTTP_HOST', environ.get('SERVER_NAME')).lower()
if ':' in host:
host, port = host.split(':', 1)
else:
if environ['wsgi.url_scheme'] == 'http':
port = '80'
else:
port = '443'
path_info = environ['PATH_INFO']
path_info = self.normalize_url(path_info, False)[1]
# The MIME type for the response is determined in one of two ways:
# 1) URL path suffix (eg /servers/detail.json)
# 2) Accept header (eg application/json;q=0.8, application/xml;q=0.2)
# The API version is determined in one of three ways:
# 1) URL path prefix (eg /v1.1/tenant/servers/detail)
# 2) Content-Type header (eg application/json;version=1.1)
# 3) Accept header (eg application/json;q=0.8;version=1.1)
supported_content_types = list(wsgi.SUPPORTED_CONTENT_TYPES)
mime_type, app, app_url = self._path_strategy(host, port, path_info)
# Accept application/atom+xml for the index query of each API
# version mount point as well as the root index
if (app_url and app_url + '/' == path_info) or path_info == '/':
supported_content_types.append('application/atom+xml')
if not app:
app = self._content_type_strategy(host, port, environ)
if not mime_type or not app:
possible_mime_type, possible_app = self._accept_strategy(
host, port, environ, supported_content_types)
if possible_mime_type and not mime_type:
mime_type = possible_mime_type
if possible_app and not app:
app = possible_app
if not mime_type:
mime_type = 'application/json'
if not app:
# Didn't match a particular version, probably matches default
app, app_url = self._match(host, port, path_info)
if app:
app = self._munge_path(app, path_info, app_url)
if app:
environ['nova.best_content_type'] = mime_type
return app(environ, start_response)
environ['paste.urlmap_object'] = self
return self.not_found_application(environ, start_response)

View File

@ -47,11 +47,11 @@ VERSIONS = {
"media-types": [
{
"base": "application/xml",
"type": "application/vnd.openstack.compute-v1.0+xml",
"type": "application/vnd.openstack.compute+xml;version=1.0",
},
{
"base": "application/json",
"type": "application/vnd.openstack.compute-v1.0+json",
"type": "application/vnd.openstack.compute+json;version=1.0",
}
],
},
@ -76,11 +76,11 @@ VERSIONS = {
"media-types": [
{
"base": "application/xml",
"type": "application/vnd.openstack.compute-v1.1+xml",
"type": "application/vnd.openstack.compute+xml;version=1.1",
},
{
"base": "application/json",
"type": "application/vnd.openstack.compute-v1.1+json",
"type": "application/vnd.openstack.compute+json;version=1.1",
}
],
},
@ -106,13 +106,7 @@ class Versions(wsgi.Resource):
body_serializers=body_serializers,
headers_serializer=headers_serializer)
supported_content_types = ('application/json',
'application/vnd.openstack.compute+json',
'application/xml',
'application/vnd.openstack.compute+xml',
'application/atom+xml')
deserializer = VersionsRequestDeserializer(
supported_content_types=supported_content_types)
deserializer = VersionsRequestDeserializer()
wsgi.Resource.__init__(self, None, serializer=serializer,
deserializer=deserializer)
@ -141,15 +135,6 @@ class VersionV11(object):
class VersionsRequestDeserializer(wsgi.RequestDeserializer):
def get_expected_content_type(self, request):
supported_content_types = list(self.supported_content_types)
if request.path != '/':
# Remove atom+xml accept type for 300 responses
if 'application/atom+xml' in supported_content_types:
supported_content_types.remove('application/atom+xml')
return request.best_match_content_type(supported_content_types)
def get_action_args(self, request_environment):
"""Parse dictionary created by routes library."""
args = {}
@ -309,13 +294,7 @@ def create_resource(version='1.0'):
}
serializer = wsgi.ResponseSerializer(body_serializers)
supported_content_types = ('application/json',
'application/vnd.openstack.compute+json',
'application/xml',
'application/vnd.openstack.compute+xml',
'application/atom+xml')
deserializer = wsgi.RequestDeserializer(
supported_content_types=supported_content_types)
deserializer = wsgi.RequestDeserializer()
return wsgi.Resource(controller, serializer=serializer,
deserializer=deserializer)

View File

@ -43,7 +43,7 @@ _CONTENT_TYPE_MAP = {
'application/vnd.openstack.compute+xml': 'application/xml',
}
_SUPPORTED_CONTENT_TYPES = (
SUPPORTED_CONTENT_TYPES = (
'application/json',
'application/vnd.openstack.compute+json',
'application/xml',
@ -54,25 +54,26 @@ _SUPPORTED_CONTENT_TYPES = (
class Request(webob.Request):
"""Add some Openstack API-specific logic to the base webob.Request."""
def best_match_content_type(self, supported_content_types=None):
"""Determine the requested response content-type.
def best_match_content_type(self):
"""Determine the requested response content-type."""
if 'nova.best_content_type' not in self.environ:
# Calculate the best MIME type
content_type = None
Based on the query extension then the Accept header.
# Check URL path suffix
parts = self.path.rsplit('.', 1)
if len(parts) > 1:
possible_type = 'application/' + parts[1]
if possible_type in SUPPORTED_CONTENT_TYPES:
content_type = possible_type
"""
supported_content_types = supported_content_types or \
_SUPPORTED_CONTENT_TYPES
if not content_type:
content_type = self.accept.best_match(SUPPORTED_CONTENT_TYPES)
parts = self.path.rsplit('.', 1)
if len(parts) > 1:
ctype = 'application/{0}'.format(parts[1])
if ctype in supported_content_types:
return ctype
self.environ['nova.best_content_type'] = content_type or \
'application/json'
bm = self.accept.best_match(supported_content_types)
# default to application/json if we don't find a preference
return bm or 'application/json'
return self.environ['nova.best_content_type']
def get_content_type(self):
"""Determine content type of the request body.
@ -83,7 +84,7 @@ class Request(webob.Request):
if not "Content-Type" in self.headers:
return None
allowed_types = _SUPPORTED_CONTENT_TYPES
allowed_types = SUPPORTED_CONTENT_TYPES
content_type = self.content_type
if content_type not in allowed_types:
@ -219,12 +220,7 @@ class RequestHeadersDeserializer(ActionDispatcher):
class RequestDeserializer(object):
"""Break up a Request object into more useful pieces."""
def __init__(self, body_deserializers=None, headers_deserializer=None,
supported_content_types=None):
self.supported_content_types = supported_content_types or \
_SUPPORTED_CONTENT_TYPES
def __init__(self, body_deserializers=None, headers_deserializer=None):
self.body_deserializers = {
'application/xml': XMLDeserializer(),
'application/json': JSONDeserializer(),
@ -287,7 +283,7 @@ class RequestDeserializer(object):
raise exception.InvalidContentType(content_type=content_type)
def get_expected_content_type(self, request):
return request.best_match_content_type(self.supported_content_types)
return request.best_match_content_type()
def get_action_args(self, request_environment):
"""Parse dictionary created by routes library."""

View File

@ -17,7 +17,6 @@
import webob
import webob.dec
from paste import urlmap
from glance import client as glance_client
@ -32,6 +31,7 @@ from nova.api.openstack import auth
from nova.api.openstack import extensions
from nova.api.openstack import versions
from nova.api.openstack import limits
from nova.api.openstack import urlmap
from nova.auth.manager import User, Project
import nova.image.fake
from nova.tests.glance import stubs as glance_stubs

View File

@ -0,0 +1,111 @@
# Copyright 2011 OpenStack LLC.
# 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.
import json
import webob
from nova import test
from nova import log as logging
from nova.tests.api.openstack import fakes
LOG = logging.getLogger('nova.tests.api.openstack.test_urlmap')
class UrlmapTest(test.TestCase):
def setUp(self):
super(UrlmapTest, self).setUp()
fakes.stub_out_rate_limiting(self.stubs)
def test_path_version_v1_0(self):
"""Test URL path specifying v1.0 returns v1.0 content."""
req = webob.Request.blank('/v1.0/')
req.accept = "application/json"
res = req.get_response(fakes.wsgi_app())
self.assertEqual(res.status_int, 200)
self.assertEqual(res.content_type, "application/json")
body = json.loads(res.body)
self.assertEqual(body['version']['id'], 'v1.0')
def test_path_version_v1_1(self):
"""Test URL path specifying v1.1 returns v1.1 content."""
req = webob.Request.blank('/v1.1/')
req.accept = "application/json"
res = req.get_response(fakes.wsgi_app())
self.assertEqual(res.status_int, 200)
self.assertEqual(res.content_type, "application/json")
body = json.loads(res.body)
self.assertEqual(body['version']['id'], 'v1.1')
def test_content_type_version_v1_0(self):
"""Test Content-Type specifying v1.0 returns v1.0 content."""
req = webob.Request.blank('/')
req.content_type = "application/json;version=1.0"
req.accept = "application/json"
res = req.get_response(fakes.wsgi_app())
self.assertEqual(res.status_int, 200)
self.assertEqual(res.content_type, "application/json")
body = json.loads(res.body)
self.assertEqual(body['version']['id'], 'v1.0')
def test_content_type_version_v1_1(self):
"""Test Content-Type specifying v1.1 returns v1.1 content."""
req = webob.Request.blank('/')
req.content_type = "application/json;version=1.1"
req.accept = "application/json"
res = req.get_response(fakes.wsgi_app())
self.assertEqual(res.status_int, 200)
self.assertEqual(res.content_type, "application/json")
body = json.loads(res.body)
self.assertEqual(body['version']['id'], 'v1.1')
def test_accept_version_v1_0(self):
"""Test Accept header specifying v1.0 returns v1.0 content."""
req = webob.Request.blank('/')
req.accept = "application/json;version=1.0"
res = req.get_response(fakes.wsgi_app())
self.assertEqual(res.status_int, 200)
self.assertEqual(res.content_type, "application/json")
body = json.loads(res.body)
self.assertEqual(body['version']['id'], 'v1.0')
def test_accept_version_v1_1(self):
"""Test Accept header specifying v1.1 returns v1.1 content."""
req = webob.Request.blank('/')
req.accept = "application/json;version=1.1"
res = req.get_response(fakes.wsgi_app())
self.assertEqual(res.status_int, 200)
self.assertEqual(res.content_type, "application/json")
body = json.loads(res.body)
self.assertEqual(body['version']['id'], 'v1.1')
def test_path_content_type(self):
"""Test URL path specifying JSON returns JSON content."""
req = webob.Request.blank('/v1.1/foobar/images/1.json')
req.accept = "application/xml"
res = req.get_response(fakes.wsgi_app())
self.assertEqual(res.status_int, 200)
self.assertEqual(res.content_type, "application/json")
body = json.loads(res.body)
self.assertEqual(body['image']['id'], '1')
def test_accept_content_type(self):
"""Test Accept header specifying JSON returns JSON content."""
req = webob.Request.blank('/v1.1/foobar/images/1')
req.accept = "application/xml;q=0.8, application/json"
res = req.get_response(fakes.wsgi_app())
self.assertEqual(res.status_int, 200)
self.assertEqual(res.content_type, "application/json")
body = json.loads(res.body)
self.assertEqual(body['image']['id'], '1')

View File

@ -56,11 +56,11 @@ VERSIONS = {
"media-types": [
{
"base": "application/xml",
"type": "application/vnd.openstack.compute-v1.0+xml",
"type": "application/vnd.openstack.compute+xml;version=1.0",
},
{
"base": "application/json",
"type": "application/vnd.openstack.compute-v1.0+json",
"type": "application/vnd.openstack.compute+json;version=1.0",
},
],
},
@ -85,11 +85,11 @@ VERSIONS = {
"media-types": [
{
"base": "application/xml",
"type": "application/vnd.openstack.compute-v1.1+xml",
"type": "application/vnd.openstack.compute+xml;version=1.1",
},
{
"base": "application/json",
"type": "application/vnd.openstack.compute-v1.1+json",
"type": "application/vnd.openstack.compute+json;version=1.1",
},
],
},
@ -175,12 +175,12 @@ class VersionsTest(test.TestCase):
{
"base": "application/xml",
"type": "application/"
"vnd.openstack.compute-v1.0+xml",
"vnd.openstack.compute+xml;version=1.0",
},
{
"base": "application/json",
"type": "application/"
"vnd.openstack.compute-v1.0+json",
"vnd.openstack.compute+json;version=1.0",
},
],
},
@ -221,12 +221,58 @@ class VersionsTest(test.TestCase):
{
"base": "application/xml",
"type": "application/"
"vnd.openstack.compute-v1.1+xml",
"vnd.openstack.compute+xml;version=1.1",
},
{
"base": "application/json",
"type": "application/"
"vnd.openstack.compute-v1.1+json",
"vnd.openstack.compute+json;version=1.1",
},
],
},
}
self.assertEqual(expected, version)
def test_get_version_1_1_detail_content_type(self):
req = webob.Request.blank('/')
req.accept = "application/json;version=1.1"
res = req.get_response(fakes.wsgi_app())
self.assertEqual(res.status_int, 200)
self.assertEqual(res.content_type, "application/json")
version = json.loads(res.body)
expected = {
"version": {
"id": "v1.1",
"status": "CURRENT",
"updated": "2011-01-21T11:33:21Z",
"links": [
{
"rel": "self",
"href": "http://localhost/v1.1/",
},
{
"rel": "describedby",
"type": "application/pdf",
"href": "http://docs.rackspacecloud.com/"
"servers/api/v1.1/cs-devguide-20110125.pdf",
},
{
"rel": "describedby",
"type": "application/vnd.sun.wadl+xml",
"href": "http://docs.rackspacecloud.com/"
"servers/api/v1.1/application.wadl",
},
],
"media-types": [
{
"base": "application/xml",
"type": "application/"
"vnd.openstack.compute+xml;version=1.1",
},
{
"base": "application/json",
"type": "application/"
"vnd.openstack.compute+json;version=1.1",
},
],
},
@ -445,11 +491,13 @@ class VersionsTest(test.TestCase):
"media-types": [
{
"base": "application/xml",
"type": "application/vnd.openstack.compute-v1.1+xml"
"type": "application/vnd.openstack.compute+xml"
";version=1.1"
},
{
"base": "application/json",
"type": "application/vnd.openstack.compute-v1.1+json"
"type": "application/vnd.openstack.compute+json"
";version=1.1"
},
],
},
@ -465,11 +513,13 @@ class VersionsTest(test.TestCase):
"media-types": [
{
"base": "application/xml",
"type": "application/vnd.openstack.compute-v1.0+xml"
"type": "application/vnd.openstack.compute+xml"
";version=1.0"
},
{
"base": "application/json",
"type": "application/vnd.openstack.compute-v1.0+json"
"type": "application/vnd.openstack.compute+json"
";version=1.0"
},
],
},
@ -543,11 +593,13 @@ class VersionsTest(test.TestCase):
"media-types": [
{
"base": "application/xml",
"type": "application/vnd.openstack.compute-v1.1+xml"
"type": "application/vnd.openstack.compute+xml"
";version=1.1"
},
{
"base": "application/json",
"type": "application/vnd.openstack.compute-v1.1+json"
"type": "application/vnd.openstack.compute+json"
";version=1.1"
},
],
},
@ -563,11 +615,13 @@ class VersionsTest(test.TestCase):
"media-types": [
{
"base": "application/xml",
"type": "application/vnd.openstack.compute-v1.0+xml"
"type": "application/vnd.openstack.compute+xml"
";version=1.0"
},
{
"base": "application/json",
"type": "application/vnd.openstack.compute-v1.0+json"
"type": "application/vnd.openstack.compute+json"
";version=1.0"
},
],
},
@ -727,11 +781,13 @@ class VersionsSerializerTests(test.TestCase):
"media-types": [
{
"base": "application/xml",
"type": "application/vnd.openstack.compute-v1.0+xml",
"type": "application/vnd.openstack.compute+xml"
";version=1.0",
},
{
"base": "application/json",
"type": "application/vnd.openstack.compute-v1.0+json",
"type": "application/vnd.openstack.compute+json"
";version=1.0",
},
],
},
@ -831,11 +887,13 @@ class VersionsSerializerTests(test.TestCase):
"media-types": [
{
"base": "application/xml",
"type": "application/vnd.openstack.compute-v1.1+xml",
"type": "application/vnd.openstack.compute+xml"
";version=1.1",
},
{
"base": "application/json",
"type": "application/vnd.openstack.compute-v1.1+json",
"type": "application/vnd.openstack.compute+json"
";version=1.1",
}
],
},

View File

@ -21,7 +21,7 @@ wsgiref==0.1.2
mox==0.5.3
greenlet==0.3.1
nose
PasteDeploy
PasteDeploy==1.5.0
paste
sqlalchemy-migrate
netaddr