diff --git a/oslo/messaging/notify/middleware.py b/oslo/messaging/notify/middleware.py new file mode 100644 index 000000000..1bf0c66be --- /dev/null +++ b/oslo/messaging/notify/middleware.py @@ -0,0 +1,128 @@ +# Copyright (c) 2013-2014 eNovance +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +""" +Send notifications on request + +""" +import logging +import os.path +import sys +import traceback as tb + +import six +import webob.dec + +from oslo.config import cfg +from oslo import messaging +from oslo.messaging import notify +from oslo.messaging.openstack.common import context +from oslo.messaging.openstack.common.gettextutils import _LE +from oslo.messaging.openstack.common.middleware import base + +LOG = logging.getLogger(__name__) + + +def log_and_ignore_error(fn): + def wrapped(*args, **kwargs): + try: + return fn(*args, **kwargs) + except Exception as e: + LOG.exception(_LE('An exception occurred processing ' + 'the API call: %s ') % e) + return wrapped + + +class RequestNotifier(base.Middleware): + """Send notification on request.""" + + @classmethod + def factory(cls, global_conf, **local_conf): + """Factory method for paste.deploy.""" + conf = global_conf.copy() + conf.update(local_conf) + + def _factory(app): + return cls(app, **conf) + return _factory + + def __init__(self, app, **conf): + self.notifier = notify.Notifier( + messaging.get_transport(cfg.CONF, conf.get('url')), + publisher_id=conf.get('publisher_id', + os.path.basename(sys.argv[0]))) + self.service_name = conf.get('service_name') + self.ignore_req_list = [x.upper().strip() for x in + conf.get('ignore_req_list', '').split(',')] + super(RequestNotifier, self).__init__(app) + + @staticmethod + def environ_to_dict(environ): + """Following PEP 333, server variables are lower case, so don't + include them. + + """ + return dict((k, v) for k, v in six.iteritems(environ) + if k.isupper() and k != 'HTTP_X_AUTH_TOKEN') + + @log_and_ignore_error + def process_request(self, request): + request.environ['HTTP_X_SERVICE_NAME'] = \ + self.service_name or request.host + payload = { + 'request': self.environ_to_dict(request.environ), + } + + self.notifier.info(context.get_admin_context(), + 'http.request', + payload) + + @log_and_ignore_error + def process_response(self, request, response, + exception=None, traceback=None): + payload = { + 'request': self.environ_to_dict(request.environ), + } + + if response: + payload['response'] = { + 'status': response.status, + 'headers': response.headers, + } + + if exception: + payload['exception'] = { + 'value': repr(exception), + 'traceback': tb.format_tb(traceback) + } + + self.notifier.info(context.get_admin_context(), + 'http.response', + payload) + + @webob.dec.wsgify + def __call__(self, req): + if req.method in self.ignore_req_list: + return req.get_response(self.application) + else: + self.process_request(req) + try: + response = req.get_response(self.application) + except Exception: + exc_type, value, traceback = sys.exc_info() + self.process_response(req, None, value, traceback) + raise + else: + self.process_response(req, response) + return response diff --git a/requirements.txt b/requirements.txt index 932b534b9..e36cd3975 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,3 +22,6 @@ PyYAML>=3.1.0 # rabbit driver is the default kombu>=2.4.8 + +# middleware +WebOb>=1.2.3 diff --git a/tests/notify/test_middleware.py b/tests/notify/test_middleware.py new file mode 100644 index 000000000..0eb3cac2e --- /dev/null +++ b/tests/notify/test_middleware.py @@ -0,0 +1,190 @@ +# Copyright 2013-2014 eNovance +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import uuid + +import mock +import webob + +from oslo.messaging.notify import middleware +from tests import utils + + +class FakeApp(object): + def __call__(self, env, start_response): + body = 'Some response' + start_response('200 OK', [ + ('Content-Type', 'text/plain'), + ('Content-Length', str(sum(map(len, body)))) + ]) + return [body] + + +class FakeFailingApp(object): + def __call__(self, env, start_response): + raise Exception("It happens!") + + +class NotifierMiddlewareTest(utils.BaseTestCase): + + def test_notification(self): + m = middleware.RequestNotifier(FakeApp()) + req = webob.Request.blank('/foo/bar', + environ={'REQUEST_METHOD': 'GET', + 'HTTP_X_AUTH_TOKEN': uuid.uuid4()}) + with mock.patch( + 'oslo.messaging.notify.notifier.Notifier._notify') as notify: + m(req) + # Check first notification with only 'request' + call_args = notify.call_args_list[0][0] + self.assertEqual(call_args[1], 'http.request') + self.assertEqual(call_args[3], 'INFO') + self.assertEqual(set(call_args[2].keys()), + set(['request'])) + + request = call_args[2]['request'] + self.assertEqual(request['PATH_INFO'], '/foo/bar') + self.assertEqual(request['REQUEST_METHOD'], 'GET') + self.assertIn('HTTP_X_SERVICE_NAME', request) + self.assertNotIn('HTTP_X_AUTH_TOKEN', request) + self.assertFalse(any(map(lambda s: s.startswith('wsgi.'), + request.keys())), + "WSGI fields are filtered out") + + # Check second notification with request + response + call_args = notify.call_args_list[1][0] + self.assertEqual(call_args[1], 'http.response') + self.assertEqual(call_args[3], 'INFO') + self.assertEqual(set(call_args[2].keys()), + set(['request', 'response'])) + + request = call_args[2]['request'] + self.assertEqual(request['PATH_INFO'], '/foo/bar') + self.assertEqual(request['REQUEST_METHOD'], 'GET') + self.assertIn('HTTP_X_SERVICE_NAME', request) + self.assertNotIn('HTTP_X_AUTH_TOKEN', request) + self.assertFalse(any(map(lambda s: s.startswith('wsgi.'), + request.keys())), + "WSGI fields are filtered out") + + response = call_args[2]['response'] + self.assertEqual(response['status'], '200 OK') + self.assertEqual(response['headers']['content-length'], '13') + + def test_notification_response_failure(self): + m = middleware.RequestNotifier(FakeFailingApp()) + req = webob.Request.blank('/foo/bar', + environ={'REQUEST_METHOD': 'GET', + 'HTTP_X_AUTH_TOKEN': uuid.uuid4()}) + with mock.patch( + 'oslo.messaging.notify.notifier.Notifier._notify') as notify: + try: + m(req) + self.fail("Application exception has not been re-raised") + except Exception: + pass + # Check first notification with only 'request' + call_args = notify.call_args_list[0][0] + self.assertEqual(call_args[1], 'http.request') + self.assertEqual(call_args[3], 'INFO') + self.assertEqual(set(call_args[2].keys()), + set(['request'])) + + request = call_args[2]['request'] + self.assertEqual(request['PATH_INFO'], '/foo/bar') + self.assertEqual(request['REQUEST_METHOD'], 'GET') + self.assertIn('HTTP_X_SERVICE_NAME', request) + self.assertNotIn('HTTP_X_AUTH_TOKEN', request) + self.assertFalse(any(map(lambda s: s.startswith('wsgi.'), + request.keys())), + "WSGI fields are filtered out") + + # Check second notification with 'request' and 'exception' + call_args = notify.call_args_list[1][0] + self.assertEqual(call_args[1], 'http.response') + self.assertEqual(call_args[3], 'INFO') + self.assertEqual(set(call_args[2].keys()), + set(['request', 'exception'])) + + request = call_args[2]['request'] + self.assertEqual(request['PATH_INFO'], '/foo/bar') + self.assertEqual(request['REQUEST_METHOD'], 'GET') + self.assertIn('HTTP_X_SERVICE_NAME', request) + self.assertNotIn('HTTP_X_AUTH_TOKEN', request) + self.assertFalse(any(map(lambda s: s.startswith('wsgi.'), + request.keys())), + "WSGI fields are filtered out") + + exception = call_args[2]['exception'] + self.assertIn('middleware.py', exception['traceback'][0]) + self.assertIn('It happens!', exception['traceback'][-1]) + self.assertEqual(exception['value'], "Exception('It happens!',)") + + def test_process_request_fail(self): + def notify_error(context, publisher_id, event_type, + priority, payload): + raise Exception('error') + with mock.patch('oslo.messaging.notify.notifier.Notifier._notify', + notify_error): + m = middleware.RequestNotifier(FakeApp()) + req = webob.Request.blank('/foo/bar', + environ={'REQUEST_METHOD': 'GET'}) + m.process_request(req) + + def test_process_response_fail(self): + def notify_error(context, publisher_id, event_type, + priority, payload): + raise Exception('error') + with mock.patch('oslo.messaging.notify.notifier.Notifier._notify', + notify_error): + m = middleware.RequestNotifier(FakeApp()) + req = webob.Request.blank('/foo/bar', + environ={'REQUEST_METHOD': 'GET'}) + m.process_response(req, webob.response.Response()) + + def test_ignore_req_opt(self): + m = middleware.RequestNotifier(FakeApp(), + ignore_req_list='get, PUT') + req = webob.Request.blank('/skip/foo', + environ={'REQUEST_METHOD': 'GET'}) + req1 = webob.Request.blank('/skip/foo', + environ={'REQUEST_METHOD': 'PUT'}) + req2 = webob.Request.blank('/accept/foo', + environ={'REQUEST_METHOD': 'POST'}) + with mock.patch( + 'oslo.messaging.notify.notifier.Notifier._notify') as notify: + # Check GET request does not send notification + m(req) + m(req1) + self.assertEqual(len(notify.call_args_list), 0) + + # Check non-GET request does send notification + m(req2) + self.assertEqual(len(notify.call_args_list), 2) + call_args = notify.call_args_list[0][0] + self.assertEqual(call_args[1], 'http.request') + self.assertEqual(call_args[3], 'INFO') + self.assertEqual(set(call_args[2].keys()), + set(['request'])) + + request = call_args[2]['request'] + self.assertEqual(request['PATH_INFO'], '/accept/foo') + self.assertEqual(request['REQUEST_METHOD'], 'POST') + + call_args = notify.call_args_list[1][0] + self.assertEqual(call_args[1], 'http.response') + self.assertEqual(call_args[3], 'INFO') + self.assertEqual(set(call_args[2].keys()), + set(['request', 'response']))