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 d515fde691..646bb4c43a 100644 --- a/keystone/common/wsgi.py +++ b/keystone/common/wsgi.py @@ -29,6 +29,7 @@ import webob.exc from keystone.common import config 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 from keystone.openstack.common import log as logging @@ -123,7 +124,14 @@ def validate_token_bind(context, token_ref): 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): @@ -231,16 +239,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')) @@ -364,13 +374,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): @@ -472,7 +485,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 @@ -566,12 +580,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)