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:
parent
14e090154c
commit
760856e966
1
.gitignore
vendored
1
.gitignore
vendored
@ -27,3 +27,4 @@ etc/logging.conf
|
|||||||
keystone/tests/tmp/
|
keystone/tests/tmp/
|
||||||
.project
|
.project
|
||||||
.pydevproject
|
.pydevproject
|
||||||
|
keystone/locale/*/LC_MESSAGES/*.mo
|
||||||
|
@ -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',
|
||||||
|
@ -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
|
||||||
==========================
|
==========================
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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)
|
||||||
|
Loading…
Reference in New Issue
Block a user