Enable localizable REST API responses via the Accept-Language header

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 exception messages to the user requested language and
return that translation from the API.

Partially implements bp user-locale-api.

Change-Id: I63edc8463836bfff257daa8a2c66ed5d3a444254
This commit is contained in:
Luis A. Garcia 2013-07-30 18:52:05 +00:00
parent 44fdf47f53
commit 1d988b4c22
4 changed files with 101 additions and 0 deletions

View File

@ -23,6 +23,7 @@ import webob.exc
from neutron.api.v2 import attributes from neutron.api.v2 import attributes
from neutron.common import exceptions from neutron.common import exceptions
from neutron.openstack.common import gettextutils
from neutron.openstack.common import log as logging from neutron.openstack.common import log as logging
from neutron import wsgi from neutron import wsgi
@ -70,6 +71,7 @@ def Resource(controller, faults=None, deserializers=None, serializers=None):
action = args.pop('action', None) action = args.pop('action', None)
content_type = format_types.get(fmt, content_type = format_types.get(fmt,
request.best_match_content_type()) request.best_match_content_type())
language = request.best_match_language()
deserializer = deserializers.get(content_type) deserializer = deserializers.get(content_type)
serializer = serializers.get(content_type) serializer = serializers.get(content_type)
@ -83,6 +85,7 @@ def Resource(controller, faults=None, deserializers=None, serializers=None):
except (exceptions.NeutronException, except (exceptions.NeutronException,
netaddr.AddrFormatError) as e: netaddr.AddrFormatError) as e:
LOG.exception(_('%s failed'), action) LOG.exception(_('%s failed'), action)
e = translate(e, language)
body = serializer.serialize({'NeutronError': e}) body = serializer.serialize({'NeutronError': e})
kwargs = {'body': body, 'content_type': content_type} kwargs = {'body': body, 'content_type': content_type}
for fault in faults: for fault in faults:
@ -91,10 +94,12 @@ def Resource(controller, faults=None, deserializers=None, serializers=None):
raise webob.exc.HTTPInternalServerError(**kwargs) raise webob.exc.HTTPInternalServerError(**kwargs)
except webob.exc.HTTPException as e: except webob.exc.HTTPException as e:
LOG.exception(_('%s failed'), action) LOG.exception(_('%s failed'), action)
translate(e, language)
e.body = serializer.serialize({'NeutronError': e}) e.body = serializer.serialize({'NeutronError': e})
e.content_type = content_type e.content_type = content_type
raise raise
except NotImplementedError as e: except NotImplementedError as e:
e = translate(e, language)
# NOTE(armando-migliaccio): from a client standpoint # NOTE(armando-migliaccio): from a client standpoint
# it makes sense to receive these errors, because # it makes sense to receive these errors, because
# extensions may or may not be implemented by # extensions may or may not be implemented by
@ -111,6 +116,7 @@ def Resource(controller, faults=None, deserializers=None, serializers=None):
# Do not expose details of 500 error to clients. # Do not expose details of 500 error to clients.
msg = _('Request Failed: internal server error while ' msg = _('Request Failed: internal server error while '
'processing your request.') 'processing your request.')
msg = translate(msg, language)
body = serializer.serialize({'NeutronError': msg}) body = serializer.serialize({'NeutronError': msg})
kwargs = {'body': body, 'content_type': content_type} kwargs = {'body': body, 'content_type': content_type}
raise webob.exc.HTTPInternalServerError(**kwargs) raise webob.exc.HTTPInternalServerError(**kwargs)
@ -126,3 +132,24 @@ def Resource(controller, faults=None, deserializers=None, serializers=None):
content_type=content_type, content_type=content_type,
body=body) body=body)
return resource return resource
def translate(translatable, locale):
"""Translates the object to the given locale.
If the object is an exception its translatable elements are translated
in place, if the object is a translatable string it is translated and
returned. Otherwise, the object is returned as-is.
:param translatable: the object to be translated
:param locale: the locale to translate to
:returns: the translated object, or the object as-is if it
was not translated
"""
localize = gettextutils.get_localized_message
if isinstance(translatable, Exception):
translatable.message = localize(translatable.message, locale)
if isinstance(translatable, webob.exc.HTTPError):
translatable.detail = localize(translatable.detail, locale)
return translatable
return localize(translatable, locale)

View File

@ -27,6 +27,9 @@ from oslo.config import cfg
from neutron.common import config from neutron.common import config
from neutron import service from neutron import service
from neutron.openstack.common import gettextutils
gettextutils.install('neutron', lazy=True)
def main(): def main():
eventlet.monkey_patch() eventlet.monkey_patch()

View File

@ -25,6 +25,7 @@ import webtest
from neutron.api.v2 import resource as wsgi_resource from neutron.api.v2 import resource as wsgi_resource
from neutron.common import exceptions as q_exc from neutron.common import exceptions as q_exc
from neutron import context from neutron import context
from neutron.openstack.common import gettextutils
from neutron.tests import base from neutron.tests import base
from neutron import wsgi from neutron import wsgi
@ -98,8 +99,23 @@ class RequestTestCase(base.BaseTestCase):
def test_context_without_neutron_context(self): def test_context_without_neutron_context(self):
self.assertTrue(self.req.context.is_admin) self.assertTrue(self.req.context.is_admin)
def test_best_match_language(self):
# Here we test that we are actually invoking language negotiation
# by webop and also that the default locale always available is en-US
request = wsgi.Request.blank('/')
gettextutils.get_available_languages = mock.MagicMock()
gettextutils.get_available_languages.return_value = ['known-language',
'es', 'zh']
request.headers['Accept-Language'] = 'known-language'
language = request.best_match_language()
self.assertEqual(language, 'known-language')
request.headers['Accept-Language'] = 'unknown-language'
language = request.best_match_language()
self.assertEqual(language, 'en_US')
class ResourceTestCase(base.BaseTestCase): class ResourceTestCase(base.BaseTestCase):
def test_unmapped_neutron_error_with_json(self): def test_unmapped_neutron_error_with_json(self):
msg = u'\u7f51\u7edc' msg = u'\u7f51\u7edc'
@ -136,6 +152,29 @@ class ResourceTestCase(base.BaseTestCase):
self.assertEqual(wsgi.XMLDeserializer().deserialize(res.body), self.assertEqual(wsgi.XMLDeserializer().deserialize(res.body),
expected_res) expected_res)
@mock.patch('neutron.openstack.common.gettextutils.Message.data',
new_callable=mock.PropertyMock)
def test_unmapped_neutron_error_localized(self, mock_translation):
gettextutils.install('blaa', lazy=True)
msg_translation = 'Translated error'
mock_translation.return_value = msg_translation
msg = _('Unmapped error')
class TestException(q_exc.NeutronException):
message = msg
controller = mock.MagicMock()
controller.test.side_effect = TestException()
resource = webtest.TestApp(wsgi_resource.Resource(controller))
environ = {'wsgiorg.routing_args': (None, {'action': 'test',
'format': 'json'})}
res = resource.get('', extra_environ=environ, expect_errors=True)
self.assertEqual(res.status_int, exc.HTTPInternalServerError.code)
self.assertIn(msg_translation,
str(wsgi.JSONDeserializer().deserialize(res.body)))
def test_mapped_neutron_error_with_json(self): def test_mapped_neutron_error_with_json(self):
msg = u'\u7f51\u7edc' msg = u'\u7f51\u7edc'
@ -176,6 +215,31 @@ class ResourceTestCase(base.BaseTestCase):
self.assertEqual(wsgi.XMLDeserializer().deserialize(res.body), self.assertEqual(wsgi.XMLDeserializer().deserialize(res.body),
expected_res) expected_res)
@mock.patch('neutron.openstack.common.gettextutils.Message.data',
new_callable=mock.PropertyMock)
def test_mapped_neutron_error_localized(self, mock_translation):
gettextutils.install('blaa', lazy=True)
msg_translation = 'Translated error'
mock_translation.return_value = msg_translation
msg = _('Unmapped error')
class TestException(q_exc.NeutronException):
message = msg
controller = mock.MagicMock()
controller.test.side_effect = TestException()
faults = {TestException: exc.HTTPGatewayTimeout}
resource = webtest.TestApp(wsgi_resource.Resource(controller,
faults=faults))
environ = {'wsgiorg.routing_args': (None, {'action': 'test',
'format': 'json'})}
res = resource.get('', extra_environ=environ, expect_errors=True)
self.assertEqual(res.status_int, exc.HTTPGatewayTimeout.code)
self.assertIn(msg_translation,
str(wsgi.JSONDeserializer().deserialize(res.body)))
def test_http_error(self): def test_http_error(self):
controller = mock.MagicMock() controller = mock.MagicMock()
controller.test.side_effect = exc.HTTPGatewayTimeout() controller.test.side_effect = exc.HTTPGatewayTimeout()

View File

@ -37,6 +37,7 @@ import webob.exc
from neutron.common import constants from neutron.common import constants
from neutron.common import exceptions as exception from neutron.common import exceptions as exception
from neutron import context from neutron import context
from neutron.openstack.common import gettextutils
from neutron.openstack.common import jsonutils from neutron.openstack.common import jsonutils
from neutron.openstack.common import log as logging from neutron.openstack.common import log as logging
@ -299,6 +300,12 @@ class Request(webob.Request):
return _type return _type
return None return None
def best_match_language(self):
"""Determine language for returned response."""
all_languages = gettextutils.get_available_languages('neutron')
return self.accept_language.best_match(all_languages,
default_match='en_US')
@property @property
def context(self): def context(self):
if 'neutron.context' not in self.environ: if 'neutron.context' not in self.environ: