Add support for API message localization

Add support for doing language resolution for a request, based on the
Accept-Language HTTP header.

Using the lazy gettext functionality from oslo gettextutils, it is
possible to use the resolved language to translate an exception message
to the user requested language and return that translation from the API.

Co-authored-by: Luis A. Garcia <luis@linux.vnet.ibm.com>
Co-authored-by: Mathew Odden <mrodden@us.ibm.com>

Implements bp user-locale-api

Change-Id: Id8e92a42039d2f0b01d5c2dada733d068b2bdfeb
This commit is contained in:
Brant Knudson 2013-07-25 17:43:36 -05:00
parent 14e090154c
commit 760856e966
6 changed files with 153 additions and 13 deletions

1
.gitignore vendored
View File

@ -27,3 +27,4 @@ etc/logging.conf
keystone/tests/tmp/ keystone/tests/tmp/
.project .project
.pydevproject .pydevproject
keystone/locale/*/LC_MESSAGES/*.mo

View File

@ -67,7 +67,10 @@ def serve(*servers):
if __name__ == '__main__': if __name__ == '__main__':
gettextutils.install('keystone') # NOTE(blk-u): Configure gettextutils for deferred translation of messages
# so that error messages in responses can be translated according to the
# Accept-Language in the request rather than the Keystone server locale.
gettextutils.install('keystone', lazy=True)
dev_conf = os.path.join(possible_topdir, dev_conf = os.path.join(possible_topdir,
'etc', 'etc',

View File

@ -228,6 +228,37 @@ installed devstack with a different LDAP password, modify the file
``keystone/tests/backend_liveldap.conf`` to reflect your password. ``keystone/tests/backend_liveldap.conf`` to reflect your password.
Translated responses
--------------------
The Keystone server can provide error responses translated into the language in
the ``Accept-Language`` header of the request. In order to test this in your
development environment, there's a couple of things you need to do.
1. Build the message files. Run the following command in your keystone
directory::
$ python setup.py compile_catalog
This will generate .mo files like keystone/locale/[lang]/LC_MESSAGES/[lang].mo
2. When running Keystone, set the ``KEYSTONE_LOCALEDIR`` environment variable
to the keystone/locale directory. For example::
$ KEYSTONE_LOCALEDIR=/opt/stack/keystone/keystone/locale keystone-all
Now you can get a translated error response::
$ curl -s -H "Accept-Language: zh" http://localhost:5000/notapath | python -mjson.tool
{
"error": {
"code": 404,
"message": "\u627e\u4e0d\u5230\u8cc7\u6e90\u3002",
"title": "Not Found"
}
}
Building the Documentation Building the Documentation
========================== ==========================

View File

@ -7,7 +7,10 @@ from keystone.common import logging
from keystone import config from keystone import config
from keystone.openstack.common import gettextutils from keystone.openstack.common import gettextutils
gettextutils.install('keystone') # NOTE(blk-u): Configure gettextutils for deferred translation of messages
# so that error messages in responses can be translated according to the
# Accept-Language in the request rather than the Keystone server locale.
gettextutils.install('keystone', lazy=True)
LOG = logging.getLogger(__name__) LOG = logging.getLogger(__name__)
CONF = config.CONF CONF = config.CONF

View File

@ -30,6 +30,7 @@ from keystone.common import config
from keystone.common import logging from keystone.common import logging
from keystone.common import utils from keystone.common import utils
from keystone import exception from keystone import exception
from keystone.openstack.common import gettextutils
from keystone.openstack.common import importutils from keystone.openstack.common import importutils
from keystone.openstack.common import jsonutils from keystone.openstack.common import jsonutils
@ -134,7 +135,14 @@ class WritableLogger(object):
class Request(webob.Request): class Request(webob.Request):
pass def best_match_language(self):
"""Determines the best available locale from the Accept-Language
HTTP header passed in the request.
"""
return self.accept_language.best_match(
gettextutils.get_available_languages('keystone'),
default_match='en_US')
class BaseApplication(object): class BaseApplication(object):
@ -242,16 +250,18 @@ class Application(BaseApplication):
LOG.warning( LOG.warning(
_('Authorization failed. %(exception)s from %(remote_addr)s') % _('Authorization failed. %(exception)s from %(remote_addr)s') %
{'exception': e, 'remote_addr': req.environ['REMOTE_ADDR']}) {'exception': e, 'remote_addr': req.environ['REMOTE_ADDR']})
return render_exception(e) return render_exception(e, user_locale=req.best_match_language())
except exception.Error as e: except exception.Error as e:
LOG.warning(e) LOG.warning(e)
return render_exception(e) return render_exception(e, user_locale=req.best_match_language())
except TypeError as e: except TypeError as e:
LOG.exception(e) LOG.exception(e)
return render_exception(exception.ValidationError(e)) return render_exception(exception.ValidationError(e),
user_locale=req.best_match_language())
except Exception as e: except Exception as e:
LOG.exception(e) LOG.exception(e)
return render_exception(exception.UnexpectedError(exception=e)) return render_exception(exception.UnexpectedError(exception=e),
user_locale=req.best_match_language())
if result is None: if result is None:
return render_response(status=(204, 'No Content')) return render_response(status=(204, 'No Content'))
@ -375,13 +385,16 @@ class Middleware(Application):
return self.process_response(request, response) return self.process_response(request, response)
except exception.Error as e: except exception.Error as e:
LOG.warning(e) LOG.warning(e)
return render_exception(e) return render_exception(e,
user_locale=request.best_match_language())
except TypeError as e: except TypeError as e:
LOG.exception(e) LOG.exception(e)
return render_exception(exception.ValidationError(e)) return render_exception(exception.ValidationError(e),
user_locale=request.best_match_language())
except Exception as e: except Exception as e:
LOG.exception(e) LOG.exception(e)
return render_exception(exception.UnexpectedError(exception=e)) return render_exception(exception.UnexpectedError(exception=e),
user_locale=request.best_match_language())
class Debug(Middleware): class Debug(Middleware):
@ -483,7 +496,8 @@ class Router(object):
match = req.environ['wsgiorg.routing_args'][1] match = req.environ['wsgiorg.routing_args'][1]
if not match: if not match:
return render_exception( return render_exception(
exception.NotFound(_('The resource could not be found.'))) exception.NotFound(_('The resource could not be found.')),
user_locale=req.best_match_language())
app = match['controller'] app = match['controller']
return app return app
@ -577,12 +591,13 @@ def render_response(body=None, status=None, headers=None):
headerlist=headers) headerlist=headers)
def render_exception(error): def render_exception(error, user_locale=None):
"""Forms a WSGI response based on the current error.""" """Forms a WSGI response based on the current error."""
body = {'error': { body = {'error': {
'code': error.code, 'code': error.code,
'title': error.title, 'title': error.title,
'message': unicode(error) 'message': unicode(gettextutils.get_localized_message(error.args[0],
user_locale)),
}} }}
if isinstance(error, exception.AuthPluginException): if isinstance(error, exception.AuthPluginException):
body['error']['identity'] = error.authentication body['error']['identity'] = error.authentication

View File

@ -14,8 +14,14 @@
# License for the specific language governing permissions and limitations # License for the specific language governing permissions and limitations
# under the License. # under the License.
import uuid
from babel import localedata
import gettext
from keystone.common import wsgi from keystone.common import wsgi
from keystone import exception from keystone import exception
from keystone.openstack.common import gettextutils
from keystone.openstack.common import jsonutils from keystone.openstack.common import jsonutils
from keystone.tests import core as test from keystone.tests import core as test
@ -211,3 +217,84 @@ class WSGIFunctionTest(test.TestCase):
message = 'test = "param1" : "value"' message = 'test = "param1" : "value"'
self.assertEqual(wsgi.mask_password(message), self.assertEqual(wsgi.mask_password(message),
'test = "param1" : "value"') 'test = "param1" : "value"')
class LocalizedResponseTest(test.TestCase):
def setUp(self):
super(LocalizedResponseTest, self).setUp()
gettextutils._AVAILABLE_LANGUAGES = []
def tearDown(self):
gettextutils._AVAILABLE_LANGUAGES = []
super(LocalizedResponseTest, self).tearDown()
def _set_expected_languages(self, all_locales=[], avail_locales=None):
# Override localedata.locale_identifiers to return some locales.
def returns_some_locales(*args, **kwargs):
return all_locales
self.stubs.Set(localedata, 'locale_identifiers', returns_some_locales)
# Override gettext.find to return other than None for some languages.
def fake_gettext_find(lang_id, *args, **kwargs):
found_ret = '/keystone/%s/LC_MESSAGES/keystone.mo' % lang_id
if avail_locales is None:
# All locales are available.
return found_ret
languages = kwargs['languages']
if languages[0] in avail_locales:
return found_ret
return None
self.stubs.Set(gettext, 'find', fake_gettext_find)
def test_request_match_default(self):
# The default language if no Accept-Language is provided is en_US
req = wsgi.Request.blank('/')
self.assertEquals(req.best_match_language(), 'en_US')
def test_request_match_language_expected(self):
# If Accept-Language is a supported language, best_match_language()
# returns it.
self._set_expected_languages(all_locales=['it'])
req = wsgi.Request.blank('/', headers={'Accept-Language': 'it'})
self.assertEquals(req.best_match_language(), 'it')
def test_request_match_language_unexpected(self):
# If Accept-Language is a language we do not support,
# best_match_language() returns the default.
self._set_expected_languages(all_locales=['it'])
req = wsgi.Request.blank('/', headers={'Accept-Language': 'zh'})
self.assertEquals(req.best_match_language(), 'en_US')
def test_localized_message(self):
# If the accept-language header is set on the request, the localized
# message is returned by calling get_localized_message.
LANG_ID = uuid.uuid4().hex
ORIGINAL_TEXT = uuid.uuid4().hex
TRANSLATED_TEXT = uuid.uuid4().hex
self._set_expected_languages(all_locales=[LANG_ID])
def fake_get_localized_message(message, user_locale):
if (user_locale == LANG_ID and
message == ORIGINAL_TEXT):
return TRANSLATED_TEXT
self.stubs.Set(gettextutils, 'get_localized_message',
fake_get_localized_message)
error = exception.NotFound(message=ORIGINAL_TEXT)
resp = wsgi.render_exception(error, user_locale=LANG_ID)
result = jsonutils.loads(resp.body)
exp = {'error': {'message': TRANSLATED_TEXT,
'code': 404,
'title': 'Not Found'}}
self.assertEqual(exp, result)