From aca713d5e0e0f5cd3e58dd45e1fd1d5c3fed141e Mon Sep 17 00:00:00 2001 From: Ziad Sawalha Date: Thu, 22 Dec 2011 02:48:46 -0600 Subject: Implement Multiple Choices Response (bug 843051) The bug allowed clients to call keystone without a version in the URL (ex. http://localhost:35357/tenants). This will prevent that behavior, but will break those clients. Change-Id: I8fdf2738371a6ceff9729fdb47563fefa0f0ec79 --- keystone/content/multiple_choice.json.tpl | 26 ++++++++++++++ keystone/content/multiple_choice.xml.tpl | 16 +++++++++ keystone/controllers/version.py | 54 +++++++++++++++++++++++++--- keystone/frontends/legacy_token_auth.py | 2 +- keystone/middleware/url.py | 49 +++++++++++++++++++------ keystone/test/unit/test_normalizingfilter.py | 9 +++++ 6 files changed, 141 insertions(+), 15 deletions(-) create mode 100644 keystone/content/multiple_choice.json.tpl create mode 100644 keystone/content/multiple_choice.xml.tpl diff --git a/keystone/content/multiple_choice.json.tpl b/keystone/content/multiple_choice.json.tpl new file mode 100644 index 00000000..578502ac --- /dev/null +++ b/keystone/content/multiple_choice.json.tpl @@ -0,0 +1,26 @@ +{ + "choices": [ + { + "id": "v{{API_VERSION}}", + "status": "{{API_VERSION_STATUS}}", + "links": [ + { + "rel": "self", + "href": "{{PROTOCOL}}://{{HOST}}:{{PORT}}/v{{API_VERSION}}{{RESOURCE_PATH}}" + } + ], + "media-types": { + "values": [ + { + "base": "application/xml", + "type": "application/vnd.openstack.identity+xml;version={{API_VERSION}}" + }, + { + "base": "application/json", + "type": "application/vnd.openstack.identity+json;version={{API_VERSION}}" + } + ] + } + } + ] +} \ No newline at end of file diff --git a/keystone/content/multiple_choice.xml.tpl b/keystone/content/multiple_choice.xml.tpl new file mode 100644 index 00000000..9f3b88a4 --- /dev/null +++ b/keystone/content/multiple_choice.xml.tpl @@ -0,0 +1,16 @@ + + + + + + + + + + \ No newline at end of file diff --git a/keystone/controllers/version.py b/keystone/controllers/version.py index 4a4f8645..c0f65ad8 100644 --- a/keystone/controllers/version.py +++ b/keystone/controllers/version.py @@ -2,7 +2,7 @@ import os from webob import Response # Calculate root path (to get to static files) -possible_topdir = os.path.normpath(os.path.join(os.path.dirname(__file__), +POSSIBLE_TOPDIR = os.path.normpath(os.path.join(os.path.dirname(__file__), os.pardir, os.pardir)) @@ -18,22 +18,27 @@ class VersionController(wsgi.Controller): self.options = options @utils.wrap_error - def get_version_info(self, req, file="version"): + def get_version_info(self, req, file="version"): resp = Response() resp.charset = 'UTF-8' if utils.is_xml_response(req): - resp_file = os.path.join(possible_topdir, + resp_file = os.path.join(POSSIBLE_TOPDIR, "keystone/content/%s.xml.tpl" % file) resp.content_type = "application/xml" else: - resp_file = os.path.join(possible_topdir, + resp_file = os.path.join(POSSIBLE_TOPDIR, "keystone/content/%s.json.tpl" % file) resp.content_type = "application/json" hostname = req.environ.get("SERVER_NAME") port = req.environ.get("SERVER_PORT") + if 'HTTPS' in req.environ: + protocol = 'https' + else: + protocol = 'http' resp.unicode_body = template.template(resp_file, + PROTOCOL=protocol, HOST=hostname, PORT=port, API_VERSION=version.API_VERSION, @@ -41,3 +46,44 @@ class VersionController(wsgi.Controller): API_VERSION_DATE=version.API_VERSION_DATE) return resp + + @utils.wrap_error + def get_multiple_choice(self, req, file="multiple_choice", path=None): + """ Returns a multiple-choices response based on API spec + + Response will include in it only one choice, which is for the + current API version. The response is a 300 Multiple Choice + response with either an XML or JSON body. + + """ + if path is None: + path = '' + resp = Response(status="300 Multiple Choices") + resp.charset = 'UTF-8' + if utils.is_xml_response(req): + resp_file = os.path.join(POSSIBLE_TOPDIR, + "keystone/content/%s.xml.tpl" % file) + resp.content_type = "application/xml" + else: + resp_file = os.path.join(POSSIBLE_TOPDIR, + "keystone/content/%s.json.tpl" % file) + resp.content_type = "application/json" + + hostname = req.environ.get("SERVER_NAME") + port = req.environ.get("SERVER_PORT") + if 'HTTPS' in req.environ: + protocol = 'https' + else: + protocol = 'http' + + resp.unicode_body = template.template(resp_file, + PROTOCOL=protocol, + HOST=hostname, + PORT=port, + API_VERSION=version.API_VERSION, + API_VERSION_STATUS=version.API_VERSION_STATUS, + API_VERSION_DATE=version.API_VERSION_DATE, + RESOURCE_PATH=path + ) + + return resp diff --git a/keystone/frontends/legacy_token_auth.py b/keystone/frontends/legacy_token_auth.py index 3dcc1c84..498357e7 100644 --- a/keystone/frontends/legacy_token_auth.py +++ b/keystone/frontends/legacy_token_auth.py @@ -52,7 +52,7 @@ class AuthProtocol(object): def __call__(self, env, start_response): """ Handle incoming request. Transform. And send downstream. """ request = Request(env) - if env['KEYSTONE_API_VERSION'] in ['1.0', '1.1']: + if env.get('KEYSTONE_API_VERSION') in ['1.0', '1.1']: params = {"auth": {"passwordCredentials": {"username": utils.get_auth_user(request), "password": utils.get_auth_key(request)}}} diff --git a/keystone/middleware/url.py b/keystone/middleware/url.py index 4bc214e4..f68a45d4 100644 --- a/keystone/middleware/url.py +++ b/keystone/middleware/url.py @@ -27,7 +27,11 @@ overwrites the Accept header in the request, if present. """ +import logging import webob.acceptparse +from webob import Request + +logger = logging.getLogger('keystone.middleware.url') # Maps supported URL prefixes to API_VERSION PATH_PREFIXES = { @@ -42,17 +46,22 @@ PATH_SUFFIXES = { # Maps supported Accept headers to RESPONSE_ENCODING and API_VERSION ACCEPT_HEADERS = { + 'application/vnd.openstack.identity+json;version=2.0': ('json', '2.0'), + 'application/vnd.openstack.identity+xml;version=2.0': ('xml', '2.0'), 'application/vnd.openstack.identity-v2.0+json': ('json', '2.0'), 'application/vnd.openstack.identity-v2.0+xml': ('xml', '2.0'), + 'application/vnd.openstack.identity+json;version=1.1': ('json', '1.1'), + 'application/vnd.openstack.identity+xml;version=1.1': ('xml', '1.1'), 'application/vnd.openstack.identity-v1.1+json': ('json', '1.1'), 'application/vnd.openstack.identity-v1.1+xml': ('xml', '1.1'), 'application/vnd.openstack.identity-v1.0+json': ('json', '1.0'), 'application/vnd.openstack.identity-v1.0+xml': ('xml', '1.0'), + 'application/vnd.openstack.identity+json;version=1.0': ('json', '1.0'), + 'application/vnd.openstack.identity+xml;version=1.0': ('xml', '1.0'), 'application/json': ('json', None), 'application/xml': ('xml', None)} DEFAULT_RESPONSE_ENCODING = 'json' -DEFAULT_API_VERSION = '2.0' class NormalizingFilter(object): @@ -72,13 +81,22 @@ class NormalizingFilter(object): env['PATH_INFO'] = normalize_trailing_slash(env['PATH_INFO']) # Fall back on defaults, if necessary - env['KEYSTONE_API_VERSION'] = env.get( - 'KEYSTONE_API_VERSION') or DEFAULT_API_VERSION env['KEYSTONE_RESPONSE_ENCODING'] = env.get( 'KEYSTONE_RESPONSE_ENCODING') or DEFAULT_RESPONSE_ENCODING env['HTTP_ACCEPT'] = 'application/' + (env.get( 'KEYSTONE_RESPONSE_ENCODING') or DEFAULT_RESPONSE_ENCODING) + if 'KEYSTONE_API_VERSION' not in env: + # Version was not specified in path or headers + # return multiple choice unless the version controller can handle + # this request + if env['PATH_INFO'] not in ['/', '']: + from keystone.controllers.version import VersionController + controller = VersionController(options=None) + response = controller.get_multiple_choice(req=Request(env), + file='multiple_choice') + return response(env, start_response) + return self.app(env, start_response) @@ -86,26 +104,37 @@ def normalize_accept_header(env): """Matches the preferred Accept encoding to supported encodings. Sets KEYSTONE_RESPONSE_ENCODING and KEYSTONE_API_VERSION, if appropriate. + + Note:: webob.acceptparse ignores ';version=' values """ accept_value = env.get('HTTP_ACCEPT') if accept_value: - try: - accept = webob.acceptparse.Accept(accept_value) - except TypeError: - # Support `webob` v1.1 and older. - accept = webob.acceptparse.Accept('Accept', accept_value) - - best_accept = accept.best_match(ACCEPT_HEADERS.keys()) + if accept_value in ACCEPT_HEADERS.keys(): + # Check for direct match first + best_accept = accept_value + else: + try: + accept = webob.acceptparse.Accept(accept_value) + except TypeError: + # Support `webob` v1.1 and older. + accept = webob.acceptparse.Accept('Accept', accept_value) + + best_accept = accept.best_match(ACCEPT_HEADERS.keys()) if best_accept: response_encoding, api_version = ACCEPT_HEADERS[best_accept] + logger.debug('%s header matched with %s (API=%s, TYPE=%s)', + accept_value, best_accept, api_version, + response_encoding) if response_encoding: env['KEYSTONE_RESPONSE_ENCODING'] = response_encoding if api_version: env['KEYSTONE_API_VERSION'] = api_version + else: + logger.debug('%s header could not be matched', accept_value) return env diff --git a/keystone/test/unit/test_normalizingfilter.py b/keystone/test/unit/test_normalizingfilter.py index e83af762..d5ac1de4 100644 --- a/keystone/test/unit/test_normalizingfilter.py +++ b/keystone/test/unit/test_normalizingfilter.py @@ -66,6 +66,15 @@ class NormalizingFilterTest(unittest.TestCase): self.assertEqual('/someresource', env['PATH_INFO']) self.assertEqual('application/json', env['HTTP_ACCEPT']) + def test_version_header(self): + env = {'PATH_INFO': '/someresource', + 'HTTP_ACCEPT': + 'application/vnd.openstack.identity+xml;version=2.0'} + self.filter(env, _start_response) + self.assertEqual('/someresource', env['PATH_INFO']) + self.assertEqual('application/xml', env['HTTP_ACCEPT']) + self.assertEqual('2.0', env['KEYSTONE_API_VERSION']) + def test_extension_overrides_header(self): env = { 'PATH_INFO': '/v2.0/someresource.json', -- cgit