From 178377817650489b294255d6a1ec49e52f8cf82c Mon Sep 17 00:00:00 2001 From: Robert Schindler Date: Mon, 22 Aug 2016 14:01:44 +0200 Subject: [PATCH] Implemented event handler middlewares + docs for them. --- docs/index.rst | 107 +++++++++++++++++++++++++++++++++++++++++- socketio/__init__.py | 3 +- socketio/namespace.py | 23 ++++----- socketio/server.py | 70 ++++++++++++++++++++------- socketio/util.py | 16 +++++++ tests/test_server.py | 100 +++++++++++++++++++++++++++++++++++++-- 6 files changed, 287 insertions(+), 32 deletions(-) create mode 100644 socketio/util.py diff --git a/docs/index.rst b/docs/index.rst index 9d6c496..d7da0a0 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -213,6 +213,103 @@ methods in the :class:`socketio.Server` class. When the ``namespace`` argument is omitted, set to ``None`` or to ``'/'``, the default namespace, representing the physical connection, is used. +Event handler middlewares +------------------------- + +Event handler middlewares are objects with the following methods or a +subset of them: + +* ``before_event(*args)`` is called before the event handler is executed + with the event name, the namespace and the list of arguments the event + handler will be called with. It may alter the arguments in that list + eventually. +* ``after_event(*args)`` is called after the event handler with the + event name, the namespace and the list of values the event handler + returned. It may alter that values eventually. +* ``ignore_event`` is called before the middleware is applied to an + event handler with the event name and namespace as arguments. If its + return value resolves to ``True`` the middleware is not applied to that + particular event handler. + +If one of these methods returns something else than ``None``, execution +is stopped at that point and the returned value is treated as if it was +returned by the event handler. + +Event handler middlewares can be chained. The ``before_event`` methods +will be executed in the same order the middlewares were added, the +``after_event`` methods are called in reversed order. + +Note that you can also use classes (objects of type ``type``) instead +of instances as event handler middlewares. If you do so, they are +instantiated with no arguments every time they are used to process +an event. + +Example: + +:: + + from socketio import server + + class MyMiddleware(object): + def ignore_event(self, event, namespace): + # This middleware won't be applied to connect and disconnect + # event handlers. + return event in ("connect", "disconnect") + + def before_event(self, event, namespace, args): + print("before_event called with:", args) + if len(args) < 2 or args[1] != "secret": + return "Ha, you don't know the password!" + + def after_event(self, event, namespace, args): + args[0] = "I faked the response!" + + sio = socketio.server.Server() + + @sio.on("/foo") + def foo(sid, data): + print("foo executed") + return "foo executed" + + sio.middlewares.append(MyMiddleware()) + +In this example, the middleware would be applied to every event handler +executed by ``sio``, except for the ``'connect'`` and ``'disconnect'`` +handlers. + +Middlewares can be added to a ``Namespace`` object as well by inserting +them into its ``middlewares`` ``list`` attribute. They are applied +after the server-wide middlewares to every event handler defined in that +``Namespace`` object. + +There is also a decorator available to add a middleware to a specific +handler only. Given the middleware class from above, it would be used +as follows: + +:: + + from socketio import util + + sio = socketio.server.Server() + + @sio.on("/foo") + @util.apply_middleware(MyMiddleware) + def foo(sid, data): + print("foo executed") + return "foo executed" + +Middlewares added by the decorator are treated as if they were added +*after* the server-wide and namespace-wide middlewares. Naturally, +decorators are applied from bottom to top. Hence the following will +first add ``MW1`` and then ``MW2``. + +:: + + @util.apply_middleware(MW2) + @util.apply_middleware(MW1) + def foo(sid): + # ... + Class-based namespaces ---------------------- @@ -251,7 +348,15 @@ Example: # ... sio = socketio.Server() - sio.register_namespace("/chat", ChatNamespace) + ns = sio.register_namespace("/chat", ChatNamespace) + # ns now holds the instantiated ChatNamespace object + +``Namespace`` objects do also support middlewares that are applied on +all event handlers in that namespace: + +:: + + ns.middlewares.append(MyMiddleware()) Using a Message Queue --------------------- diff --git a/socketio/__init__.py b/socketio/__init__.py index ff68424..514583f 100644 --- a/socketio/__init__.py +++ b/socketio/__init__.py @@ -5,6 +5,7 @@ from .pubsub_manager import PubSubManager from .kombu_manager import KombuManager from .redis_manager import RedisManager from .server import Server +from .util import apply_middleware __all__ = [Middleware, Namespace, Server, BaseManager, PubSubManager, - KombuManager, RedisManager] + KombuManager, RedisManager, apply_middleware] diff --git a/socketio/namespace.py b/socketio/namespace.py index ab3a59b..d2810ea 100644 --- a/socketio/namespace.py +++ b/socketio/namespace.py @@ -10,6 +10,7 @@ class Namespace(object): There are also the following methods available that insert the current namespace automatically when none is given before they call their matching method of the ``Server`` instance: + ``emit``, ``send``, ``enter_room``, ``leave_room``, ``close_room``, ``rooms``, ``disconnect`` @@ -33,12 +34,14 @@ class Namespace(object): # ... sio = socketio.Server() - sio.register_namespace("/chat", ChatNamespace) + ns = sio.register_namespace("/chat", ChatNamespace) + # ns now holds the instantiated ChatNamespace object """ def __init__(self, name, server): self.name = name self.server = server + self.middlewares = [] # wrap methods of Server object def get_wrapped_method(func_name): @@ -54,7 +57,7 @@ class Namespace(object): 'close_room', 'rooms', 'disconnect'): setattr(self, func_name, get_wrapped_method(func_name)) - def get_event_handler(self, event_name): + def _get_event_handler(self, event_name): """Returns the event handler for given ``event`` in this namespace or ``None``, if none exists. @@ -62,15 +65,14 @@ class Namespace(object): """ for attr_name in dir(self): attr = getattr(self, attr_name) - if hasattr(attr, '_event_name'): - _event_name = getattr(attr, '_event_name') + if hasattr(attr, '_sio_event_name'): + _event_name = getattr(attr, '_sio_event_name') elif attr_name.startswith('on_'): _event_name = attr_name[3:] else: continue if _event_name == event_name: return attr - return None @staticmethod def event_name(name): @@ -80,12 +82,11 @@ class Namespace(object): def foo(self, sid, data): return "received: %s" % data - Note that this must be the last decorator applied on an event handler - (last applied means listed first) in order to work. + Ensure that you only add well-behaving decorators after this one + (meaning such that preserve attributes) because you'll loose them + otherwise. """ def wrapper(handler): - def wrapped_handler(*args, **kwargs): - return handler(*args, **kwargs) - wrapped_handler._event_name = name - return wrapped_handler + handler._sio_event_name = name + return handler return wrapper diff --git a/socketio/server.py b/socketio/server.py index 976dada..2215199 100644 --- a/socketio/server.py +++ b/socketio/server.py @@ -78,6 +78,7 @@ class Server(object): self.environ = {} self.handlers = {} + self.middlewares = [] self._binary_packet = [] @@ -171,21 +172,6 @@ class Server(object): self.handlers[name] = namespace return namespace - def get_event_handler(self, event, namespace): - """Returns the event handler for given ``event`` and ``namespace`` or - ``None``, if none exists. - - :param event: The event name the handler is required for. - :param namespace: The Socket.IO namespace for the event. - """ - handler = None - ns = self.handlers.get(namespace) - if isinstance(ns, sio_namespace.Namespace): - handler = ns.get_event_handler(event) - elif isinstance(ns, dict): - handler = ns.get(event) - return handler - def emit(self, event, data=None, room=None, skip_sid=None, namespace=None, callback=None): """Emit a custom event to one or more connected clients. @@ -460,10 +446,62 @@ class Server(object): def _trigger_event(self, event, namespace, *args): """Invoke an application event handler.""" - handler = self.get_event_handler(event, namespace) + handler = None + middlewares = list(self.middlewares) + ns = self.handlers.get(namespace) + if isinstance(ns, sio_namespace.Namespace): + middlewares.extend(ns.middlewares) + handler = ns._get_event_handler(event) + elif isinstance(ns, dict): + handler = ns.get(event) if handler is not None: + middlewares.extend(getattr(handler, '_sio_middlewares', [])) + handler = self._apply_middlewares(middlewares, event, namespace, + handler) return handler(*args) + @staticmethod + def _apply_middlewares(middlewares, event, namespace, handler): + """Wraps the given handler with a wrapper that executes middlewares + before and after the real event handler.""" + + _middlewares = [] + for middleware in middlewares: + if isinstance(middleware, type): + middleware = middleware() + if not hasattr(middleware, 'ignore_event') or \ + not middleware.ignore_event(event, namespace): + _middlewares.append(middleware) + if not _middlewares: + return handler + + def wrapped(*args): + args = list(args) + + for middleware in _middlewares: + if hasattr(middleware, 'before_event'): + result = middleware.before_event(event, namespace, args) + if result is not None: + return result + + result = handler(*args) + if result is None: + data = [] + elif isinstance(result, tuple): + data = list(result) + else: + data = [result] + + for middleware in reversed(_middlewares): + if hasattr(middleware, 'after_event'): + result = middleware.after_event(event, namespace, data) + if result is not None: + return result + + return tuple(data) + + return wrapped + def _handle_eio_connect(self, sid, environ): """Handle the Engine.IO connection event.""" self.environ[sid] = environ diff --git a/socketio/util.py b/socketio/util.py new file mode 100644 index 0000000..9f01b4c --- /dev/null +++ b/socketio/util.py @@ -0,0 +1,16 @@ +def apply_middleware(middleware): + """Returns a decorator for event handlers that adds the given + middleware to the handler decorated with it. + + :param middleware: The middleware to add + + Ensure that you only add well-behaving decorators after this one + (meaning such that preserve attributes) because you'll loose them + otherwise. + """ + def wrapper(handler): + if not hasattr(handler, '_sio_middlewares'): + handler._sio_middlewares = [] + handler._sio_middlewares.append(middleware) + return handler + return wrapper diff --git a/tests/test_server.py b/tests/test_server.py index 08547b4..3c17573 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -11,6 +11,7 @@ else: from socketio import namespace from socketio import packet from socketio import server +from socketio import util @mock.patch('engineio.Server') @@ -58,9 +59,100 @@ class TestServer(unittest.TestCase): s = server.Server() s.register_namespace('/ns', NS) - self.assertIsNotNone(s.handlers['/ns'].get_event_handler('foo')) - self.assertIsNotNone(s.handlers['/ns'].get_event_handler('foo bar')) - self.assertIsNone(s.handlers['/ns'].get_event_handler('abc')) + self.assertIsNotNone(s.handlers['/ns']._get_event_handler('foo')) + self.assertIsNotNone(s.handlers['/ns']._get_event_handler('foo bar')) + self.assertIsNone(s.handlers['/ns']._get_event_handler('abc')) + + def test_middleware(self, eio): + class MW: + def __init__(self): + self.ignore_event = mock.MagicMock(side_effect=100 * [False]) + self.before_event = mock.MagicMock(side_effect=100 * [None]) + self.after_event = mock.MagicMock(side_effect=100 * [None]) + + mw1 = MW() + mw2 = MW() + mw3 = MW() + mw4 = MW() + mw4.ignore_event = mock.MagicMock(side_effect=[True] + 100 * [False]) + mw4.before_event = mock.MagicMock(side_effect=['x'] + 100 * [None]) + mw4.after_event = mock.MagicMock(side_effect=['x'] + 100 * [None]) + + class NS(namespace.Namespace): + def on_foo(self, sid): + pass + + @namespace.Namespace.event_name('foo bar') + @util.apply_middleware(mw4) + def some_name(self, sid): + pass + + s = server.Server() + s.middlewares.append(mw1) + + @s.on('abc') + @util.apply_middleware(mw2) + def abc(sid): + pass + + ns = s.register_namespace('/ns', NS) + ns.middlewares.append(mw3) + + # only mw1 and mw3 should run completely + s._trigger_event('foo', '/ns', '123') + self.assertEqual(mw1.before_event.call_count, 1) + self.assertEqual(mw1.after_event.call_count, 1) + self.assertEqual(mw2.before_event.call_count, 0) + self.assertEqual(mw2.after_event.call_count, 0) + self.assertEqual(mw3.before_event.call_count, 1) + self.assertEqual(mw3.after_event.call_count, 1) + self.assertEqual(mw4.before_event.call_count, 0) + self.assertEqual(mw4.after_event.call_count, 0) + + # only mw1 and mw3 should run completely, mw4 is enabled but ignored + s._trigger_event('foo bar', '/ns', '123') + self.assertEqual(mw1.before_event.call_count, 2) + self.assertEqual(mw1.after_event.call_count, 2) + self.assertEqual(mw2.before_event.call_count, 0) + self.assertEqual(mw2.after_event.call_count, 0) + self.assertEqual(mw3.before_event.call_count, 2) + self.assertEqual(mw3.after_event.call_count, 2) + self.assertEqual(mw4.before_event.call_count, 0) + self.assertEqual(mw4.after_event.call_count, 0) + + # again, this time mw4 before_event should be triggered + s._trigger_event('foo bar', '/ns', '123') + self.assertEqual(mw1.before_event.call_count, 3) + self.assertEqual(mw1.after_event.call_count, 2) + self.assertEqual(mw2.before_event.call_count, 0) + self.assertEqual(mw2.after_event.call_count, 0) + self.assertEqual(mw3.before_event.call_count, 3) + self.assertEqual(mw3.after_event.call_count, 2) + self.assertEqual(mw4.before_event.call_count, 1) + self.assertEqual(mw4.after_event.call_count, 0) + + # again, this time mw4 before + after_event should be triggered + # but after_event should abort execution + s._trigger_event('foo bar', '/ns', '123') + self.assertEqual(mw1.before_event.call_count, 4) + self.assertEqual(mw1.after_event.call_count, 2) + self.assertEqual(mw2.before_event.call_count, 0) + self.assertEqual(mw2.after_event.call_count, 0) + self.assertEqual(mw3.before_event.call_count, 4) + self.assertEqual(mw3.after_event.call_count, 2) + self.assertEqual(mw4.before_event.call_count, 2) + self.assertEqual(mw4.after_event.call_count, 1) + + # only mw1 and mw2 should run completely + s._trigger_event('abc', '/', '123') + self.assertEqual(mw1.before_event.call_count, 5) + self.assertEqual(mw1.after_event.call_count, 3) + self.assertEqual(mw2.before_event.call_count, 1) + self.assertEqual(mw2.after_event.call_count, 1) + self.assertEqual(mw3.before_event.call_count, 4) + self.assertEqual(mw3.after_event.call_count, 2) + self.assertEqual(mw4.before_event.call_count, 2) + self.assertEqual(mw4.after_event.call_count, 1) def test_on_bad_event_name(self, eio): s = server.Server() @@ -215,6 +307,7 @@ class TestServer(unittest.TestCase): mgr = mock.MagicMock() s = server.Server(client_manager=mgr) handler = mock.MagicMock(return_value=False) + del handler._sio_middlewares s.on('connect', handler) s._handle_eio_connect('123', 'environ') handler.assert_called_once_with('123', 'environ') @@ -226,6 +319,7 @@ class TestServer(unittest.TestCase): mgr = mock.MagicMock() s = server.Server(client_manager=mgr) handler = mock.MagicMock(return_value=False) + del handler._sio_middlewares s.on('connect', handler, namespace='/foo') s._handle_eio_connect('123', 'environ') s._handle_eio_message('123', '0/foo')