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: I48de5e681827305eef60c7a77e8d3091f9d64be0
Co-authored-by: Mathew Odden <mrodden@us.ibm.com>
Co-authored-by: Ben Nemec <openstack@nemebean.com>
This commit is contained in:
Brad Pokorny 2013-08-01 02:31:36 +00:00
parent 4947979514
commit e5d92994b1
6 changed files with 111 additions and 12 deletions

View File

@ -59,7 +59,8 @@ def setup_app(pecan_config=None, extra_hooks=None):
storage_engine, storage_engine,
storage_engine.get_connection(cfg.CONF), storage_engine.get_connection(cfg.CONF),
), ),
hooks.PipelineHook()] hooks.PipelineHook(),
hooks.TranslationHook()]
if extra_hooks: if extra_hooks:
app_hooks.extend(extra_hooks) app_hooks.extend(extra_hooks)

View File

@ -704,9 +704,11 @@ class ResourcesController(rest.RestController):
# FIXME (flwang): Need to change this to return a 404 error code when # FIXME (flwang): Need to change this to return a 404 error code when
# we get a release of WSME that supports it. # we get a release of WSME that supports it.
if not resources: if not resources:
error = _("Unknown resource")
pecan.response.translatable_error = error
raise wsme.exc.InvalidInput("resource_id", raise wsme.exc.InvalidInput("resource_id",
resource_id, resource_id,
_("Unknown resource")) error)
return Resource.from_db_and_links(resources[0], return Resource.from_db_and_links(resources[0],
self._resource_links(resource_id)) self._resource_links(resource_id))
@ -836,14 +838,18 @@ class AlarmsController(rest.RestController):
alarms = list(conn.get_alarms(name=data.name, alarms = list(conn.get_alarms(name=data.name,
project=data.project_id)) project=data.project_id))
if len(alarms) > 0: if len(alarms) > 0:
raise wsme.exc.ClientSideError(_("Alarm with that name exists")) error = _("Alarm with that name exists")
pecan.response.translatable_error = error
raise wsme.exc.ClientSideError(error)
try: try:
kwargs = data.as_dict(storage.models.Alarm) kwargs = data.as_dict(storage.models.Alarm)
alarm_in = storage.models.Alarm(**kwargs) alarm_in = storage.models.Alarm(**kwargs)
except Exception as ex: except Exception as ex:
LOG.exception(ex) LOG.exception(ex)
raise wsme.exc.ClientSideError(_("Alarm incorrect")) error = _("Alarm incorrect")
pecan.response.translatable_error = error
raise wsme.exc.ClientSideError(error)
alarm = conn.update_alarm(alarm_in) alarm = conn.update_alarm(alarm_in)
return Alarm.from_db_model(alarm) return Alarm.from_db_model(alarm)
@ -860,7 +866,9 @@ class AlarmsController(rest.RestController):
alarms = list(conn.get_alarms(alarm_id=alarm_id, alarms = list(conn.get_alarms(alarm_id=alarm_id,
project=auth_project)) project=auth_project))
if len(alarms) < 1: if len(alarms) < 1:
raise wsme.exc.ClientSideError(_("Unknown alarm")) error = _("Unknown alarm")
pecan.response.translatable_error = error
raise wsme.exc.ClientSideError(error)
# merge the new values from kwargs into the current # merge the new values from kwargs into the current
# alarm "alarm_in". # alarm "alarm_in".
@ -882,7 +890,9 @@ class AlarmsController(rest.RestController):
alarms = list(conn.get_alarms(alarm_id=alarm_id, alarms = list(conn.get_alarms(alarm_id=alarm_id,
project=auth_project)) project=auth_project))
if len(alarms) < 1: if len(alarms) < 1:
raise wsme.exc.ClientSideError(_("Unknown alarm")) error = _("Unknown alarm")
pecan.response.translatable_error = error
raise wsme.exc.ClientSideError(error)
conn.delete_alarm(alarm_id) conn.delete_alarm(alarm_id)
@ -896,7 +906,9 @@ class AlarmsController(rest.RestController):
# FIXME (flwang): Need to change this to return a 404 error code when # FIXME (flwang): Need to change this to return a 404 error code when
# we get a release of WSME that supports it. # we get a release of WSME that supports it.
if len(alarms) < 1: if len(alarms) < 1:
raise wsme.exc.ClientSideError(_("Unknown alarm")) error = _("Unknown alarm")
pecan.response.translatable_error = error
raise wsme.exc.ClientSideError(error)
return Alarm.from_db_model(alarms[0]) return Alarm.from_db_model(alarms[0])

View File

@ -17,6 +17,7 @@
# 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 threading
from oslo.config import cfg from oslo.config import cfg
from pecan import hooks from pecan import hooks
@ -61,3 +62,18 @@ class PipelineHook(hooks.PecanHook):
def before(self, state): def before(self, state):
state.request.pipeline_manager = self.pipeline_manager state.request.pipeline_manager = self.pipeline_manager
class TranslationHook(hooks.PecanHook):
def __init__(self):
# Use thread local storage to make this thread safe in situations
# where one pecan instance is being used to serve multiple request
# threads.
self.local_error = threading.local()
self.local_error.translatable_error = None
def after(self, state):
if hasattr(state.response, 'translatable_error'):
self.local_error.translatable_error = (
state.response.translatable_error)

View File

@ -1,5 +1,6 @@
# -*- encoding: utf-8 -*- # -*- encoding: utf-8 -*-
# #
# Copyright 2013 IBM Corp.
# Copyright © 2012 New Dream Network, LLC (DreamHost) # Copyright © 2012 New Dream Network, LLC (DreamHost)
# #
# Author: Doug Hellmann <doug.hellmann@dreamhost.com> # Author: Doug Hellmann <doug.hellmann@dreamhost.com>
@ -29,6 +30,8 @@ try:
except ImportError: except ImportError:
from xml.parsers.expat import ExpatError as ParseError from xml.parsers.expat import ExpatError as ParseError
from ceilometer.api import hooks
from ceilometer.openstack.common import gettextutils
from ceilometer.openstack.common import log from ceilometer.openstack.common import log
LOG = log.getLogger(__name__) LOG = log.getLogger(__name__)
@ -72,13 +75,32 @@ class ParsableErrorMiddleware(object):
app_iter = self.app(environ, replacement_start_response) app_iter = self.app(environ, replacement_start_response)
if (state['status_code'] / 100) not in (2, 3): if (state['status_code'] / 100) not in (2, 3):
req = webob.Request(environ) req = webob.Request(environ)
# Find the first TranslationHook in the array of hooks and use the
# translatable_error object from it
error = None
for hook in self.app.hooks:
if isinstance(hook, hooks.TranslationHook):
error = hook.local_error.translatable_error
break
user_locale = req.accept_language.best_match(
gettextutils.get_available_languages('ceilometer'),
default_match='en_US')
if (req.accept.best_match(['application/json', 'application/xml']) if (req.accept.best_match(['application/json', 'application/xml'])
== 'application/xml'): == 'application/xml'):
try: try:
# simple check xml is valid # simple check xml is valid
fault = et.ElementTree.fromstring('\n'.join(app_iter))
# Add the translated error to the xml data
if error is not None:
for fault_string in fault.findall('faultstring'):
fault_string.text = (
gettextutils.get_localized_message(
error, user_locale))
body = [et.ElementTree.tostring( body = [et.ElementTree.tostring(
et.ElementTree.fromstring('<error_message>' et.ElementTree.fromstring(
+ '\n'.join(app_iter) '<error_message>'
+ et.ElementTree.tostring(fault)
+ '</error_message>'))] + '</error_message>'))]
except ParseError as err: except ParseError as err:
LOG.error('Error parsing HTTP response: %s' % err) LOG.error('Error parsing HTTP response: %s' % err)
@ -86,6 +108,14 @@ class ParsableErrorMiddleware(object):
+ '</error_message>'] + '</error_message>']
state['headers'].append(('Content-Type', 'application/xml')) state['headers'].append(('Content-Type', 'application/xml'))
else: else:
try:
fault = json.loads('\n'.join(app_iter))
if error is not None and 'faultstring' in fault:
fault['faultstring'] = (
gettextutils.get_localized_message(
error, user_locale))
body = [json.dumps({'error_message': json.dumps(fault)})]
except ValueError as err:
body = [json.dumps({'error_message': '\n'.join(app_iter)})] body = [json.dumps({'error_message': '\n'.join(app_iter)})]
state['headers'].append(('Content-Type', 'application/json')) state['headers'].append(('Content-Type', 'application/json'))
state['headers'].append(('Content-Length', len(body[0]))) state['headers'].append(('Content-Length', len(body[0])))

View File

@ -75,7 +75,7 @@ cfg.CONF.register_cli_opts(CLI_OPTIONS, group="service_credentials")
def prepare_service(argv=None): def prepare_service(argv=None):
eventlet.monkey_patch() eventlet.monkey_patch()
gettextutils.install('ceilometer') gettextutils.install('ceilometer', True)
rpc.set_defaults(control_exchange='ceilometer') rpc.set_defaults(control_exchange='ceilometer')
cfg.set_defaults(log.log_opts, cfg.set_defaults(log.log_opts,
default_log_levels=['amqplib=WARN', default_log_levels=['amqplib=WARN',

View File

@ -1,5 +1,6 @@
# -*- encoding: utf-8 -*- # -*- encoding: utf-8 -*-
# #
# Copyright 2013 IBM Corp.
# Copyright © 2013 Julien Danjou # Copyright © 2013 Julien Danjou
# #
# Author: Julien Danjou <julien@danjou.info> # Author: Julien Danjou <julien@danjou.info>
@ -17,6 +18,7 @@
# under the License. # under the License.
"""Test basic ceilometer-api app """Test basic ceilometer-api app
""" """
import json
import os import os
from oslo.config import cfg from oslo.config import cfg
@ -24,6 +26,7 @@ from oslo.config import cfg
from ceilometer.api import app from ceilometer.api import app
from ceilometer.api import acl from ceilometer.api import acl
from ceilometer import service from ceilometer import service
from ceilometer.openstack.common import gettextutils
from ceilometer.tests import base from ceilometer.tests import base
from ceilometer.tests import db as tests_db from ceilometer.tests import db as tests_db
from .base import FunctionalTest from .base import FunctionalTest
@ -65,6 +68,11 @@ class TestApiMiddleware(FunctionalTest):
# This doesn't really matter # This doesn't really matter
database_connection = tests_db.MongoDBFakeConnectionUrl() database_connection = tests_db.MongoDBFakeConnectionUrl()
translated_error = 'Translated error'
def _fake_get_localized_message(self, message, user_locale):
return self.translated_error
def test_json_parsable_error_middleware_404(self): def test_json_parsable_error_middleware_404(self):
response = self.get_json('/invalid_path', response = self.get_json('/invalid_path',
expect_errors=True, expect_errors=True,
@ -106,6 +114,21 @@ class TestApiMiddleware(FunctionalTest):
self.assertEqual(response.content_type, "application/json") self.assertEqual(response.content_type, "application/json")
self.assertTrue(response.json['error_message']) self.assertTrue(response.json['error_message'])
def test_json_parsable_error_middleware_translation_400(self):
# Ensure translated messages get placed properly into json faults
self.stubs.Set(gettextutils, 'get_localized_message',
self._fake_get_localized_message)
response = self.get_json('/alarms/-',
expect_errors=True,
headers={"Accept":
"application/json"}
)
self.assertEqual(response.status_int, 400)
self.assertEqual(response.content_type, "application/json")
self.assertTrue(response.json['error_message'])
fault = json.loads(response.json['error_message'])
self.assertEqual(fault['faultstring'], self.translated_error)
def test_xml_parsable_error_middleware_404(self): def test_xml_parsable_error_middleware_404(self):
response = self.get_json('/invalid_path', response = self.get_json('/invalid_path',
expect_errors=True, expect_errors=True,
@ -124,3 +147,20 @@ class TestApiMiddleware(FunctionalTest):
self.assertEqual(response.status_int, 404) self.assertEqual(response.status_int, 404)
self.assertEqual(response.content_type, "application/xml") self.assertEqual(response.content_type, "application/xml")
self.assertEqual(response.xml.tag, 'error_message') self.assertEqual(response.xml.tag, 'error_message')
def test_xml_parsable_error_middleware_translation_400(self):
# Ensure translated messages get placed properly into xml faults
self.stubs.Set(gettextutils, 'get_localized_message',
self._fake_get_localized_message)
response = self.get_json('/alarms/-',
expect_errors=True,
headers={"Accept":
"application/xml,*/*"}
)
self.assertEqual(response.status_int, 400)
self.assertEqual(response.content_type, "application/xml")
self.assertEqual(response.xml.tag, 'error_message')
fault = response.xml.findall('./error/faultstring')
for fault_string in fault:
self.assertEqual(fault_string.text, self.translated_error)