diff --git a/docs/index.rst b/docs/index.rst index 885cfd0..4e35d2c 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -230,20 +230,125 @@ that belong to a namespace can be created as methods of a subclass of def on_my_event(sid, data): self.emit('my_response', data) + # Here we set the event name explicitly by decorator. + @socketio.Namespace.event_name("event name with spaces") + def foo(self, sid): + pass + sio.register_namespace(MyCustomNamespace('/test')) When class-based namespaces are used, any events received by the server are dispatched to a method named as the event name with the ``on_`` prefix. For example, event ``my_event`` will be handled by a method named ``on_my_event``. If an event is received for which there is no corresponding method defined in -the namespace class, then the event is ignored. All event names used in -class-based namespaces must used characters that are legal in method names. +the namespace class, then the event is ignored. + +All event names containing characters that are not legal in method names +must use the ``socketio.Namespace.event_name`` decorator on the event +handler method to set it manually. As a convenience to methods defined in a class-based namespace, the namespace instance includes versions of several of the methods in the :class:`socketio.Server` class that default to the proper namespace when the ``namespace`` argument is not given. +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): + # ... + Using a Message Queue --------------------- diff --git a/socketio/__init__.py b/socketio/__init__.py index d9ca049..514583f 100644 --- a/socketio/__init__.py +++ b/socketio/__init__.py @@ -1,10 +1,11 @@ from .middleware import Middleware +from .namespace import Namespace from .base_manager import BaseManager from .pubsub_manager import PubSubManager from .kombu_manager import KombuManager from .redis_manager import RedisManager from .server import Server -from .namespace import Namespace +from .util import apply_middleware -__all__ = [Middleware, Server, BaseManager, PubSubManager, KombuManager, - RedisManager, Namespace] +__all__ = [Middleware, Namespace, Server, BaseManager, PubSubManager, + KombuManager, RedisManager, apply_middleware] diff --git a/socketio/namespace.py b/socketio/namespace.py index 4772788..7eb4834 100644 --- a/socketio/namespace.py +++ b/socketio/namespace.py @@ -11,17 +11,13 @@ class Namespace(object): omitted, the default namespace is used. """ def __init__(self, namespace=None): - self.namespace = namespace or '/' + self.name = namespace or '/' self.server = None + self.middlewares = [] def set_server(self, server): self.server = server - def trigger_event(self, event, *args): - handler_name = 'on_' + event - if hasattr(self, handler_name): - return getattr(self, handler_name)(*args) - 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. @@ -31,7 +27,7 @@ class Namespace(object): associated with the class is used. """ return self.server.emit(event, data=data, room=room, skip_sid=skip_sid, - namespace=namespace or self.namespace, + namespace=namespace or self.name, callback=callback) def send(self, data, room=None, skip_sid=None, namespace=None, @@ -43,7 +39,7 @@ class Namespace(object): associated with the class is used. """ return self.server.send(data, room=room, skip_sid=skip_sid, - namespace=namespace or self.namespace, + namespace=namespace or self.name, callback=callback) def enter_room(self, sid, room, namespace=None): @@ -54,7 +50,7 @@ class Namespace(object): associated with the class is used. """ return self.server.enter_room(sid, room, - namespace=namespace or self.namespace) + namespace=namespace or self.name) def leave_room(self, sid, room, namespace=None): """Leave a room. @@ -64,7 +60,7 @@ class Namespace(object): associated with the class is used. """ return self.server.leave_room(sid, room, - namespace=namespace or self.namespace) + namespace=namespace or self.name) def close_room(self, room, namespace=None): """Close a room. @@ -74,7 +70,7 @@ class Namespace(object): associated with the class is used. """ return self.server.close_room(room, - namespace=namespace or self.namespace) + namespace=namespace or self.name) def rooms(self, sid, namespace=None): """Return the rooms a client is in. @@ -83,7 +79,7 @@ class Namespace(object): that when the ``namespace`` argument is not given the namespace associated with the class is used. """ - return self.server.rooms(sid, namespace=namespace or self.namespace) + return self.server.rooms(sid, namespace=namespace or self.name) def disconnect(self, sid, namespace=None): """Disconnect a client. @@ -93,4 +89,38 @@ class Namespace(object): associated with the class is used. """ return self.server.disconnect(sid, - namespace=namespace or self.namespace) + namespace=namespace or self.name) + + def get_event_handler(self, event_name): + """Returns the event handler for given ``event`` in this namespace or + ``None``, if none exists. + + :param event: The event name the handler is required for. + """ + for attr_name in dir(self): + attr = getattr(self, attr_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 + + @staticmethod + def event_name(name): + """Decorator to overwrite event names: + + @Namespace.event_name("event name with spaces") + def foo(self, sid, data): + return "received: %s" % data + + 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): + handler._sio_event_name = name + return handler + return wrapper diff --git a/socketio/server.py b/socketio/server.py index 6250474..f1ceb61 100644 --- a/socketio/server.py +++ b/socketio/server.py @@ -4,6 +4,7 @@ import engineio import six from . import base_manager +from . import namespace as sio_namespace from . import packet from . import namespace @@ -79,6 +80,7 @@ class Server(object): self.environ = {} self.handlers = {} self.namespace_handlers = {} + self.middlewares = [] self._binary_packet = [] @@ -143,6 +145,10 @@ class Server(object): namespace = namespace or '/' def set_handler(handler): + if isinstance(self.handlers.get(namespace), + sio_namespace.Namespace): + raise ValueError('A Namespace object has been registered ' + 'for this namespace.') if namespace not in self.handlers: self.handlers[namespace] = {} self.handlers[namespace][event] = handler @@ -162,7 +168,7 @@ class Server(object): if not isinstance(namespace_handler, namespace.Namespace): raise ValueError('Not a namespace instance') namespace_handler.set_server(self) - self.namespace_handlers[namespace_handler.namespace] = \ + self.namespace_handlers[namespace_handler.name] = \ namespace_handler def emit(self, event, data=None, room=None, skip_sid=None, namespace=None, @@ -439,14 +445,62 @@ class Server(object): def _trigger_event(self, event, namespace, *args): """Invoke an application event handler.""" + handler = None + middlewares = list(self.middlewares) # first see if we have an explicit handler for the event if namespace in self.handlers and event in self.handlers[namespace]: - return self.handlers[namespace][event](*args) - - # or else, forward the event to a namepsace handler if one exists + handler = self.handlers[namespace][event] elif namespace in self.namespace_handlers: - return self.namespace_handlers[namespace].trigger_event( - event, *args) + ns = self.namespace_handlers[namespace] + middlewares.extend(ns.middlewares) + handler = ns.get_event_handler(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.""" 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_namespace.py b/tests/test_namespace.py index f4a7bac..958ec21 100644 --- a/tests/test_namespace.py +++ b/tests/test_namespace.py @@ -18,7 +18,7 @@ class TestNamespace(unittest.TestCase): ns = MyNamespace('/foo') ns.set_server(mock.MagicMock()) - ns.trigger_event('connect', 'sid', {'foo': 'bar'}) + ns.get_event_handler('connect')('sid', {'foo': 'bar'}) self.assertEqual(result['result'], ('sid', {'foo': 'bar'})) def test_disconnect_event(self): @@ -30,7 +30,7 @@ class TestNamespace(unittest.TestCase): ns = MyNamespace('/foo') ns.set_server(mock.MagicMock()) - ns.trigger_event('disconnect', 'sid') + ns.get_event_handler('disconnect')('sid') self.assertEqual(result['result'], 'sid') def test_event(self): @@ -42,7 +42,7 @@ class TestNamespace(unittest.TestCase): ns = MyNamespace('/foo') ns.set_server(mock.MagicMock()) - ns.trigger_event('custom_message', 'sid', {'data': 'data'}) + ns.get_event_handler('custom_message')('sid', {'data': 'data'}) self.assertEqual(result['result'], ('sid', {'data': 'data'})) def test_event_not_found(self): @@ -54,7 +54,7 @@ class TestNamespace(unittest.TestCase): ns = MyNamespace('/foo') ns.set_server(mock.MagicMock()) - ns.trigger_event('another_custom_message', 'sid', {'data': 'data'}) + self.assertIsNone(ns.get_event_handler('another_custom_message')) self.assertEqual(result, {}) def test_emit(self): diff --git a/tests/test_server.py b/tests/test_server.py index fafacf7..8f14ac7 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -8,9 +8,10 @@ if six.PY3: else: import mock +from socketio import namespace from socketio import packet from socketio import server -from socketio import namespace +from socketio import util @mock.patch('engineio.Server') @@ -46,6 +47,98 @@ class TestServer(unittest.TestCase): self.assertEqual(s.handlers['/']['disconnect'], bar) self.assertEqual(s.handlers['/foo']['disconnect'], bar) + 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 = NS('/ns') + ns.middlewares.append(mw3) + s.register_namespace(ns) + + # 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() self.assertRaises(ValueError, s.on, 'two-words') @@ -199,6 +292,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') @@ -210,6 +304,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')