From 760856e9669b0af20909c56d4a5a082bd4d7e450 Mon Sep 17 00:00:00 2001 From: Brant Knudson Date: Thu, 25 Jul 2013 17:43:36 -0500 Subject: [PATCH] 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 Co-authored-by: Mathew Odden Implements bp user-locale-api Change-Id: Id8e92a42039d2f0b01d5c2dada733d068b2bdfeb --- .gitignore | 1 + bin/keystone-all | 5 ++- doc/source/developing.rst | 31 +++++++++++++ httpd/keystone.py | 5 ++- keystone/common/wsgi.py | 37 +++++++++++----- keystone/tests/test_wsgi.py | 87 +++++++++++++++++++++++++++++++++++++ 6 files changed, 153 insertions(+), 13 deletions(-) diff --git a/.gitignore b/.gitignore index 1297ba42c4..26415aface 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ etc/logging.conf keystone/tests/tmp/ .project .pydevproject +keystone/locale/*/LC_MESSAGES/*.mo diff --git a/bin/keystone-all b/bin/keystone-all index bb755606e9..187a2ee152 100755 --- a/bin/keystone-all +++ b/bin/keystone-all @@ -67,7 +67,10 @@ def serve(*servers): 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, 'etc', diff --git a/doc/source/developing.rst b/doc/source/developing.rst index 7029e1c82b..312d7892d9 100644 --- a/doc/source/developing.rst +++ b/doc/source/developing.rst @@ -228,6 +228,37 @@ installed devstack with a different LDAP password, modify the file ``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 ========================== diff --git a/httpd/keystone.py b/httpd/keystone.py index c737354943..492d2519e0 100644 --- a/httpd/keystone.py +++ b/httpd/keystone.py @@ -7,7 +7,10 @@ from keystone.common import logging from keystone import config 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__) CONF = config.CONF diff --git a/keystone/common/wsgi.py b/keystone/common/wsgi.py index f47cde1379..ae199d7494 100644 --- a/keystone/common/wsgi.py +++ b/keystone/common/wsgi.py @@ -30,6 +30,7 @@ from keystone.common import config from keystone.common import logging from keystone.common import utils from keystone import exception +from keystone.openstack.common import gettextutils from keystone.openstack.common import importutils from keystone.openstack.common import jsonutils @@ -134,7 +135,14 @@ class WritableLogger(object): 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): @@ -242,16 +250,18 @@ class Application(BaseApplication): LOG.warning( _('Authorization failed. %(exception)s from %(remote_addr)s') % {'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: LOG.warning(e) - return render_exception(e) + return render_exception(e, user_locale=req.best_match_language()) except TypeError as 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: 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: return render_response(status=(204, 'No Content')) @@ -375,13 +385,16 @@ class Middleware(Application): return self.process_response(request, response) except exception.Error as e: LOG.warning(e) - return render_exception(e) + return render_exception(e, + user_locale=request.best_match_language()) except TypeError as 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: 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): @@ -483,7 +496,8 @@ class Router(object): match = req.environ['wsgiorg.routing_args'][1] if not match: 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'] return app @@ -577,12 +591,13 @@ def render_response(body=None, status=None, headers=None): headerlist=headers) -def render_exception(error): +def render_exception(error, user_locale=None): """Forms a WSGI response based on the current error.""" body = {'error': { 'code': error.code, 'title': error.title, - 'message': unicode(error) + 'message': unicode(gettextutils.get_localized_message(error.args[0], + user_locale)), }} if isinstance(error, exception.AuthPluginException): body['error']['identity'] = error.authentication diff --git a/keystone/tests/test_wsgi.py b/keystone/tests/test_wsgi.py index 781159e209..0dfa946744 100644 --- a/keystone/tests/test_wsgi.py +++ b/keystone/tests/test_wsgi.py @@ -14,8 +14,14 @@ # License for the specific language governing permissions and limitations # under the License. +import uuid + +from babel import localedata +import gettext + from keystone.common import wsgi from keystone import exception +from keystone.openstack.common import gettextutils from keystone.openstack.common import jsonutils from keystone.tests import core as test @@ -211,3 +217,84 @@ class WSGIFunctionTest(test.TestCase): message = 'test = "param1" : "value"' self.assertEqual(wsgi.mask_password(message), '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)