Add support for API message localization

Using the lazy gettext functionality from oslo gettextutils,
it is possible to use the Accept-Language header to translate
an exception message to the user requested locale and return
that translation from the API.

Implements bp user-locale-api

Change-Id: I446fdf4b9843155e0c818c271114b1e2e4f43b64
This commit is contained in:
Matt Odden 2013-05-24 05:37:17 +00:00
parent 7fa2264625
commit ee53124ccb
12 changed files with 131 additions and 17 deletions

View File

@ -1,5 +1,6 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4 # vim: tabstop=4 shiftwidth=4 softtabstop=4
# Copyright 2013 IBM Corp.
# Copyright 2011 OpenStack Foundation # Copyright 2011 OpenStack Foundation
# All Rights Reserved. # All Rights Reserved.
# #
@ -25,6 +26,7 @@ import webob
from nova.api.openstack import xmlutil from nova.api.openstack import xmlutil
from nova import exception from nova import exception
from nova.openstack.common import gettextutils
from nova.openstack.common.gettextutils import _ from nova.openstack.common.gettextutils import _
from nova.openstack.common import jsonutils from nova.openstack.common import jsonutils
from nova.openstack.common import log as logging from nova.openstack.common import log as logging
@ -176,6 +178,12 @@ class Request(webob.Request):
return content_type return content_type
def best_match_language(self):
"""Determine language for returned response."""
return self.accept_language.best_match(
gettextutils.get_available_languages('nova'),
default_match='en_US')
class ActionDispatcher(object): class ActionDispatcher(object):
"""Maps method name to local methods through action name.""" """Maps method name to local methods through action name."""
@ -925,7 +933,7 @@ class Resource(wsgi.Application):
"%(body)s") % {'action': action, "%(body)s") % {'action': action,
'body': unicode(body, 'utf-8')} 'body': unicode(body, 'utf-8')}
LOG.debug(msg) LOG.debug(msg)
LOG.debug(_("Calling method %s") % meth) LOG.debug(_("Calling method %s") % str(meth))
# Now, deserialize the request body... # Now, deserialize the request body...
try: try:
@ -1180,6 +1188,8 @@ class Fault(webob.exc.HTTPException):
@webob.dec.wsgify(RequestClass=Request) @webob.dec.wsgify(RequestClass=Request)
def __call__(self, req): def __call__(self, req):
"""Generate a WSGI response based on the exception passed to ctor.""" """Generate a WSGI response based on the exception passed to ctor."""
user_locale = req.best_match_language()
# Replace the body with fault details. # Replace the body with fault details.
code = self.wrapped_exc.status_int code = self.wrapped_exc.status_int
fault_name = self._fault_names.get(code, "computeFault") fault_name = self._fault_names.get(code, "computeFault")
@ -1187,6 +1197,8 @@ class Fault(webob.exc.HTTPException):
LOG.debug(_("Returning %(code)s to user: %(explanation)s"), LOG.debug(_("Returning %(code)s to user: %(explanation)s"),
{'code': code, 'explanation': explanation}) {'code': code, 'explanation': explanation})
explanation = gettextutils.get_localized_message(explanation,
user_locale)
fault_data = { fault_data = {
fault_name: { fault_name: {
'code': code, 'code': code,
@ -1250,9 +1262,19 @@ class RateLimitFault(webob.exc.HTTPException):
Return the wrapped exception with a serialized body conforming to our Return the wrapped exception with a serialized body conforming to our
error format. error format.
""" """
user_locale = request.best_match_language()
content_type = request.best_match_content_type() content_type = request.best_match_content_type()
metadata = {"attributes": {"overLimit": ["code", "retryAfter"]}} metadata = {"attributes": {"overLimit": ["code", "retryAfter"]}}
self.content['overLimit']['message'] = \
gettextutils.get_localized_message(
self.content['overLimit']['message'],
user_locale)
self.content['overLimit']['details'] = \
gettextutils.get_localized_message(
self.content['overLimit']['details'],
user_locale)
xml_serializer = XMLDictSerializer(metadata, XMLNS_V11) xml_serializer = XMLDictSerializer(metadata, XMLNS_V11)
serializer = { serializer = {
'application/xml': xml_serializer, 'application/xml': xml_serializer,

View File

@ -32,3 +32,6 @@ os.environ['EVENTLET_NO_GREENDNS'] = 'yes'
import eventlet import eventlet
eventlet.monkey_patch(os=False) eventlet.monkey_patch(os=False)
from nova.openstack.common import gettextutils
gettextutils.enable_lazy()

View File

@ -911,7 +911,7 @@ class ComputeManager(manager.SchedulerDependentManager):
info = extra_usage_info.copy() info = extra_usage_info.copy()
if not msg: if not msg:
msg = "" msg = ""
info['message'] = msg info['message'] = unicode(msg)
self._notify_about_instance_usage(context, instance, type_, self._notify_about_instance_usage(context, instance, type_,
extra_usage_info=info, **kwargs) extra_usage_info=info, **kwargs)

View File

@ -27,6 +27,7 @@ import eventlet
eventlet.monkey_patch(os=False) eventlet.monkey_patch(os=False)
import copy import copy
import gettext
import os import os
import shutil import shutil
import sys import sys
@ -191,6 +192,17 @@ class MoxStubout(fixtures.Fixture):
self.addCleanup(self.mox.VerifyAll) self.addCleanup(self.mox.VerifyAll)
class TranslationFixture(fixtures.Fixture):
"""Use gettext NullTranslation objects in tests."""
def setUp(self):
super(TranslationFixture, self).setUp()
nulltrans = gettext.NullTranslations()
gettext_fixture = fixtures.MonkeyPatch('gettext.translation',
lambda *x, **y: nulltrans)
self.gettext_patcher = self.useFixture(gettext_fixture)
class TestingException(Exception): class TestingException(Exception):
pass pass
@ -216,6 +228,7 @@ class TestCase(testtools.TestCase):
self.useFixture(fixtures.Timeout(test_timeout, gentle=True)) self.useFixture(fixtures.Timeout(test_timeout, gentle=True))
self.useFixture(fixtures.NestedTempfile()) self.useFixture(fixtures.NestedTempfile())
self.useFixture(fixtures.TempHomeDir()) self.useFixture(fixtures.TempHomeDir())
self.useFixture(TranslationFixture())
if (os.environ.get('OS_STDOUT_CAPTURE') == 'True' or if (os.environ.get('OS_STDOUT_CAPTURE') == 'True' or
os.environ.get('OS_STDOUT_CAPTURE') == '1'): os.environ.get('OS_STDOUT_CAPTURE') == '1'):

View File

@ -17,7 +17,6 @@
import uuid import uuid
from lxml import etree from lxml import etree
import testtools
import webob import webob
from nova.api.openstack.compute.contrib import floating_ips from nova.api.openstack.compute.contrib import floating_ips
@ -306,9 +305,10 @@ class FloatingIpTest(test.TestCase):
self.stubs.Set(network.api.API, "allocate_floating_ip", fake_allocate) self.stubs.Set(network.api.API, "allocate_floating_ip", fake_allocate)
req = fakes.HTTPRequest.blank('/v2/fake/os-floating-ips') req = fakes.HTTPRequest.blank('/v2/fake/os-floating-ips')
with testtools.ExpectedException(webob.exc.HTTPNotFound, ex = self.assertRaises(webob.exc.HTTPNotFound,
'No more floating ips'): self.controller.create, req)
self.controller.create(req)
self.assertIn('No more floating ips', ex.explanation)
def test_floating_ip_allocate_no_free_ips_pool(self): def test_floating_ip_allocate_no_free_ips_pool(self):
def fake_allocate(*args, **kwargs): def fake_allocate(*args, **kwargs):
@ -317,10 +317,11 @@ class FloatingIpTest(test.TestCase):
self.stubs.Set(network.api.API, "allocate_floating_ip", fake_allocate) self.stubs.Set(network.api.API, "allocate_floating_ip", fake_allocate)
req = fakes.HTTPRequest.blank('/v2/fake/os-floating-ips') req = fakes.HTTPRequest.blank('/v2/fake/os-floating-ips')
with testtools.ExpectedException( ex = self.assertRaises(webob.exc.HTTPNotFound,
webob.exc.HTTPNotFound, self.controller.create, req, {'pool': 'non_existant_pool'})
'No more floating ips in pool non_existant_pool'):
self.controller.create(req, {'pool': 'non_existant_pool'}) self.assertIn('No more floating ips in pool non_existant_pool',
ex.explanation)
def test_floating_ip_allocate(self): def test_floating_ip_allocate(self):
def fake1(*args, **kwargs): def fake1(*args, **kwargs):

View File

@ -699,3 +699,8 @@ def stub_snapshot_get_all(self, context):
def stub_bdm_get_all_by_instance(context, instance_uuid): def stub_bdm_get_all_by_instance(context, instance_uuid):
return [{'source_type': 'volume', 'volume_id': 'volume_id1'}, return [{'source_type': 'volume', 'volume_id': 'volume_id1'},
{'source_type': 'volume', 'volume_id': 'volume_id2'}] {'source_type': 'volume', 'volume_id': 'volume_id2'}]
def fake_get_available_languages(domain):
existing_translations = ['en_GB', 'en_AU', 'de', 'zh_CN', 'en_US']
return existing_translations

View File

@ -1,5 +1,6 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4 # vim: tabstop=4 shiftwidth=4 softtabstop=4
# Copyright 2013 IBM Corp.
# Copyright 2010 OpenStack Foundation # Copyright 2010 OpenStack Foundation
# All Rights Reserved. # All Rights Reserved.
# #
@ -23,6 +24,7 @@ import webob.exc
from nova.api.openstack import common from nova.api.openstack import common
from nova.api.openstack import wsgi from nova.api.openstack import wsgi
from nova.openstack.common import gettextutils
from nova.openstack.common import jsonutils from nova.openstack.common import jsonutils
from nova import test from nova import test
@ -138,6 +140,22 @@ class TestFaults(test.TestCase):
self.assertTrue('resizeNotAllowed' not in resp.body) self.assertTrue('resizeNotAllowed' not in resp.body)
self.assertTrue('forbidden' in resp.body) self.assertTrue('forbidden' in resp.body)
def test_raise_localize_explanation(self):
msgid = "String with params: %s"
params = ('blah', )
lazy_gettext = gettextutils._
expl = lazy_gettext(msgid) % params
@webob.dec.wsgify
def raiser(req):
raise wsgi.Fault(webob.exc.HTTPNotFound(explanation=expl))
req = webob.Request.blank('/.xml')
resp = req.get_response(raiser)
self.assertEqual(resp.content_type, "application/xml")
self.assertEqual(resp.status_int, 404)
self.assertTrue((msgid % params) in resp.body)
def test_fault_has_status_int(self): def test_fault_has_status_int(self):
# Ensure the status_int is set correctly on faults. # Ensure the status_int is set correctly on faults.
fault = wsgi.Fault(webob.exc.HTTPBadRequest(explanation='what?')) fault = wsgi.Fault(webob.exc.HTTPBadRequest(explanation='what?'))

View File

@ -5,6 +5,7 @@ import webob
from nova.api.openstack import wsgi from nova.api.openstack import wsgi
from nova import exception from nova import exception
from nova.openstack.common import gettextutils
from nova import test from nova import test
from nova.tests.api.openstack import fakes from nova.tests.api.openstack import fakes
from nova.tests import utils from nova.tests import utils
@ -97,6 +98,53 @@ class RequestTest(test.TestCase):
'uuid1': instances[1], 'uuid1': instances[1],
'uuid2': instances[2]}) 'uuid2': instances[2]})
def test_from_request(self):
self.stubs.Set(gettextutils, 'get_available_languages',
fakes.fake_get_available_languages)
request = wsgi.Request.blank('/')
accepted = 'bogus;q=1.1, en-gb;q=0.7,en-us,en;q=.5,*;q=.7'
request.headers = {'Accept-Language': accepted}
self.assertEqual(request.best_match_language(), 'en_US')
def test_asterisk(self):
# asterisk should match first available if there
# are not any other available matches
self.stubs.Set(gettextutils, 'get_available_languages',
fakes.fake_get_available_languages)
request = wsgi.Request.blank('/')
accepted = '*,es;q=.5'
request.headers = {'Accept-Language': accepted}
self.assertEqual(request.best_match_language(), 'en_GB')
def test_prefix(self):
self.stubs.Set(gettextutils, 'get_available_languages',
fakes.fake_get_available_languages)
request = wsgi.Request.blank('/')
accepted = 'zh'
request.headers = {'Accept-Language': accepted}
self.assertEqual(request.best_match_language(), 'zh_CN')
def test_secondary(self):
self.stubs.Set(gettextutils, 'get_available_languages',
fakes.fake_get_available_languages)
request = wsgi.Request.blank('/')
accepted = 'nn,en-gb;q=.5'
request.headers = {'Accept-Language': accepted}
self.assertEqual(request.best_match_language(), 'en_GB')
def test_none_found(self):
self.stubs.Set(gettextutils, 'get_available_languages',
fakes.fake_get_available_languages)
request = wsgi.Request.blank('/')
accepted = 'nb-no'
request.headers = {'Accept-Language': accepted}
self.assertEqual(request.best_match_language(), 'en_US')
class ActionDispatcherTest(test.TestCase): class ActionDispatcherTest(test.TestCase):
def test_dispatch(self): def test_dispatch(self):

View File

@ -78,9 +78,9 @@ class TestKeystoneMiddlewareRoles(test.TestCase):
context = req.environ['nova.context'] context = req.environ['nova.context']
if "knight" in context.roles and "bad" not in context.roles: if "knight" in context.roles and "bad" not in context.roles:
return webob.Response(status=_("200 Role Match")) return webob.Response(status="200 Role Match")
elif context.roles == ['']: elif context.roles == ['']:
return webob.Response(status=_("200 No Roles")) return webob.Response(status="200 No Roles")
else: else:
raise webob.exc.HTTPBadRequest(_("unexpected role header")) raise webob.exc.HTTPBadRequest(_("unexpected role header"))

View File

@ -43,6 +43,7 @@ from oslo.config import cfg
from nova import exception from nova import exception
from nova.openstack.common import excutils from nova.openstack.common import excutils
from nova.openstack.common import gettextutils
from nova.openstack.common.gettextutils import _ from nova.openstack.common.gettextutils import _
from nova.openstack.common import importutils from nova.openstack.common import importutils
from nova.openstack.common import lockutils from nova.openstack.common import lockutils
@ -476,6 +477,8 @@ def utf8(value):
""" """
if isinstance(value, unicode): if isinstance(value, unicode):
return value.encode('utf-8') return value.encode('utf-8')
elif isinstance(value, gettextutils.Message):
return unicode(value).encode('utf-8')
assert isinstance(value, str) assert isinstance(value, str)
return value return value

View File

@ -1004,7 +1004,7 @@ class ComputeDriver(object):
""" """
if not self._compute_event_callback: if not self._compute_event_callback:
LOG.debug(_("Discarding event %s") % event) LOG.debug(_("Discarding event %s") % str(event))
return return
if not isinstance(event, virtevent.Event): if not isinstance(event, virtevent.Event):
@ -1012,7 +1012,7 @@ class ComputeDriver(object):
_("Event must be an instance of nova.virt.event.Event")) _("Event must be an instance of nova.virt.event.Event"))
try: try:
LOG.debug(_("Emitting event %s") % event) LOG.debug(_("Emitting event %s") % str(event))
self._compute_event_callback(event) self._compute_event_callback(event)
except Exception as ex: except Exception as ex:
LOG.error(_("Exception dispatching event %(event)s: %(ex)s"), LOG.error(_("Exception dispatching event %(event)s: %(ex)s"),

View File

@ -583,7 +583,8 @@ class LibvirtDriver(driver.ComputeDriver):
self._wrapped_conn = wrapped_conn self._wrapped_conn = wrapped_conn
try: try:
LOG.debug(_("Registering for lifecycle events %s") % self) LOG.debug(_("Registering for lifecycle events %s") %
str(self))
wrapped_conn.domainEventRegisterAny( wrapped_conn.domainEventRegisterAny(
None, None,
libvirt.VIR_DOMAIN_EVENT_ID_LIFECYCLE, libvirt.VIR_DOMAIN_EVENT_ID_LIFECYCLE,
@ -595,8 +596,8 @@ class LibvirtDriver(driver.ComputeDriver):
if self.has_min_version(MIN_LIBVIRT_CLOSE_CALLBACK_VERSION): if self.has_min_version(MIN_LIBVIRT_CLOSE_CALLBACK_VERSION):
try: try:
LOG.debug(_("Registering for connection events: %s") LOG.debug(_("Registering for connection events: %s") %
% self) str(self))
wrapped_conn.registerCloseCallback( wrapped_conn.registerCloseCallback(
self._close_callback, None) self._close_callback, None)
except libvirt.libvirtError: except libvirt.libvirtError: