diff options
| author | 2018-02-17 16:40:12 +0000 | |
|---|---|---|
| committer | 2018-02-17 16:40:12 +0000 | |
| commit | 13b99667fa35ee913c314d5ec0cdb51d5835a98a (patch) | |
| tree | 61e49b8668b6d51523b5dd25e4ffd00bbeca27ec | |
| parent | snekchek (diff) | |
Integrate websockets into the Flask webapp
| -rw-r--r-- | pysite/base_route.py | 52 | ||||
| -rw-r--r-- | pysite/mixins.py | 63 | ||||
| -rw-r--r-- | pysite/route_manager.py | 35 | ||||
| -rw-r--r-- | pysite/views/api/bot/tag.py | 5 | ||||
| -rw-r--r-- | pysite/views/api/bot/user.py | 6 | ||||
| -rw-r--r-- | pysite/views/ws/__init__.py | 0 | ||||
| -rw-r--r-- | pysite/views/ws/echo.py | 18 | ||||
| -rw-r--r-- | pysite/websockets.py | 97 | ||||
| -rw-r--r-- | requirements.txt | 1 | ||||
| -rw-r--r-- | templates/ws_test.html | 2 | ||||
| -rw-r--r-- | ws_app.py | 28 | 
11 files changed, 214 insertions, 93 deletions
| diff --git a/pysite/base_route.py b/pysite/base_route.py index 8e6648ee..36b7fef5 100644 --- a/pysite/base_route.py +++ b/pysite/base_route.py @@ -3,15 +3,10 @@ import os  import random  import string -from _weakref import ref -  from flask import Blueprint, jsonify, render_template  from flask.views import MethodView -from rethinkdb.ast import Table -  from pysite.constants import ErrorCodes -from pysite.database import RethinkDB  class BaseView(MethodView): @@ -124,53 +119,6 @@ class APIView(RouteView):          return response -class DBViewMixin: -    """ -    Mixin for views that make use of RethinkDB. It can automatically create a table with the specified primary -    key using the attributes set at class-level. - -    This class is intended to be mixed in alongside one of the other view classes. For example: - -    >>> class MyView(APIView, DBViewMixin): -    ...     name = "my_view"  # Flask internal name for this route -    ...     path = "/my_view"  # Actual URL path to reach this route -    ...     table_name = "my_table"  # Name of the table to create -    ...     table_primary_key = "username"  # Primary key to set for this table - -    You may omit `table_primary_key` and it will be defaulted to RethinkDB's default column - "id". -    """ - -    table_name = ""  # type: str -    table_primary_key = "id"  # type: str - -    @classmethod -    def setup(cls: "DBViewMixin", manager: "pysite.route_manager.RouteManager", blueprint: Blueprint): -        """ -        Set up the view by creating the table specified by the class attributes - this will also deal with multiple -        inheritance by calling `super().setup()` as appropriate. - -        :param manager: Instance of the current RouteManager (used to get a handle for the database object) -        :param blueprint: Current Flask blueprint -        """ - -        if hasattr(super(), "setup"): -            super().setup(manager, blueprint) - -        if not cls.table_name: -            raise RuntimeError("Routes using DBViewMixin must define `table_name`") - -        cls._db = ref(manager.db) -        manager.db.create_table(cls.table_name, primary_key=cls.table_primary_key) - -    @property -    def table(self) -> Table: -        return self.db.query(self.table_name) - -    @property -    def db(self) -> RethinkDB: -        return self._db() - -  class ErrorView(BaseView):      """      Error view, shown for a specific HTTP status code, as defined in the class attributes. diff --git a/pysite/mixins.py b/pysite/mixins.py new file mode 100644 index 00000000..891aa185 --- /dev/null +++ b/pysite/mixins.py @@ -0,0 +1,63 @@ +# coding=utf-8 +from _weakref import ref + +from flask import Blueprint + +from rethinkdb.ast import Table + +from pysite.database import RethinkDB + + +class DBMixin: +    """ +    Mixin for classes that make use of RethinkDB. It can automatically create a table with the specified primary +    key using the attributes set at class-level. + +    This class is intended to be mixed in alongside one of the other view classes. For example: + +    >>> class MyView(APIView, DBMixin): +    ...     name = "my_view"  # Flask internal name for this route +    ...     path = "/my_view"  # Actual URL path to reach this route +    ...     table_name = "my_table"  # Name of the table to create +    ...     table_primary_key = "username"  # Primary key to set for this table + +    This class will also work with Websockets: + +    >>> class MyWeboscket(Websocket, DBMixin): +    ...     name = "my_websocket" +    ...     path = "/my_websocket" +    ...     table_name = "my_table" +    ...     table_primary_key = "username" + +    You may omit `table_primary_key` and it will be defaulted to RethinkDB's default column - "id". +    """ + +    table_name = ""  # type: str +    table_primary_key = "id"  # type: str + +    @classmethod +    def setup(cls: "DBMixin", manager: "pysite.route_manager.RouteManager", blueprint: Blueprint): +        """ +        Set up the view by creating the table specified by the class attributes - this will also deal with multiple +        inheritance by calling `super().setup()` as appropriate. + +        :param manager: Instance of the current RouteManager (used to get a handle for the database object) +        :param blueprint: Current Flask blueprint +        """ + +        if hasattr(super(), "setup"): +            super().setup(manager, blueprint) + +        if not cls.table_name: +            raise RuntimeError("Routes using DBViewMixin must define `table_name`") + +        cls._db = ref(manager.db) +        manager.db.create_table(cls.table_name, primary_key=cls.table_primary_key) + +    @property +    def table(self) -> Table: +        return self.db.query(self.table_name) + +    @property +    def db(self) -> RethinkDB: +        return self._db() diff --git a/pysite/route_manager.py b/pysite/route_manager.py index 3a23619e..b3f71643 100644 --- a/pysite/route_manager.py +++ b/pysite/route_manager.py @@ -5,8 +5,11 @@ import os  from flask import Blueprint, Flask +from flask_sockets import Sockets +  from pysite.base_route import APIView, BaseView, ErrorView, RouteView  from pysite.database import RethinkDB +from pysite.websockets import Websocket  TEMPLATES_PATH = "../templates"  STATIC_PATH = "../static" @@ -19,6 +22,8 @@ class RouteManager:          self.app = Flask(              __name__, template_folder=TEMPLATES_PATH, static_folder=STATIC_PATH, static_url_path="/static",          ) +        self.sockets = Sockets(self.app) +          self.db = RethinkDB()          self.app.secret_key = os.environ.get("WEBPAGE_SECRET_KEY", "super_secret")          self.app.config["SERVER_NAME"] = os.environ.get("SERVER_NAME", "pythondiscord.com:8080") @@ -36,17 +41,29 @@ class RouteManager:          self.subdomains = ['api', 'staff']          for sub in self.subdomains: -            self.sub_blueprint = Blueprint(sub, __name__, subdomain=sub) +            sub_blueprint = Blueprint(sub, __name__, subdomain=sub) -            print(f"Loading Blueprint: {self.sub_blueprint.name}") -            self.load_views(self.sub_blueprint, f"pysite/views/{sub}") -            self.app.register_blueprint(self.sub_blueprint) +            print(f"Loading Blueprint: {sub_blueprint.name}") +            self.load_views(sub_blueprint, f"pysite/views/{sub}") +            self.app.register_blueprint(sub_blueprint)              print("") +        # Load the websockets +        self.ws_blueprint = Blueprint("ws", __name__) + +        print("Loading websocket routes...") +        self.load_views(self.ws_blueprint, "pysite/views/ws") +        self.sockets.register_blueprint(self.ws_blueprint, url_prefix="/ws") +      def run(self): -        self.app.run( -            port=int(os.environ.get("WEBPAGE_PORT", 8080)), debug="FLASK_DEBUG" in os.environ +        from gevent.pywsgi import WSGIServer +        from geventwebsocket.handler import WebSocketHandler + +        server = WSGIServer( +            ("", int(os.environ.get("WEBPAGE_PORT", 8080))), +            self.app, handler_class=WebSocketHandler          ) +        server.serve_forever()      def load_views(self, blueprint, location="pysite/views"):          for filename in os.listdir(location): @@ -65,7 +82,11 @@ class RouteManager:                              cls is not ErrorView and                              cls is not RouteView and                              cls is not APIView and -                            BaseView in cls.__mro__ +                            cls is not Websocket and +                            ( +                                BaseView in cls.__mro__ or +                                Websocket in cls.__mro__ +                            )                      ):                          cls.setup(self, blueprint)                          print(f">> View loaded: {cls.name: <15} ({module.__name__}.{cls_name})") diff --git a/pysite/views/api/bot/tag.py b/pysite/views/api/bot/tag.py index 363f98fe..2117d948 100644 --- a/pysite/views/api/bot/tag.py +++ b/pysite/views/api/bot/tag.py @@ -2,12 +2,13 @@  from flask import jsonify, request -from pysite.base_route import APIView, DBViewMixin +from pysite.base_route import APIView  from pysite.constants import ErrorCodes  from pysite.decorators import api_key +from pysite.mixins import DBMixin -class TagView(APIView, DBViewMixin): +class TagView(APIView, DBMixin):      path = "/tag"      name = "tag"      table_name = "tag" diff --git a/pysite/views/api/bot/user.py b/pysite/views/api/bot/user.py index 8c2d8149..174407b8 100644 --- a/pysite/views/api/bot/user.py +++ b/pysite/views/api/bot/user.py @@ -4,10 +4,10 @@ from flask import jsonify  from schema import Schema -from pysite.base_route import APIView, DBViewMixin +from pysite.base_route import APIView  from pysite.constants import ValidationTypes  from pysite.decorators import api_key, api_params - +from pysite.mixins import DBMixin  SCHEMA = Schema([      { @@ -22,7 +22,7 @@ REQUIRED_KEYS = [  ] -class UserView(APIView, DBViewMixin): +class UserView(APIView, DBMixin):      path = "/user"      name = "user"      table_name = "users" diff --git a/pysite/views/ws/__init__.py b/pysite/views/ws/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/pysite/views/ws/__init__.py diff --git a/pysite/views/ws/echo.py b/pysite/views/ws/echo.py new file mode 100644 index 00000000..135adfcf --- /dev/null +++ b/pysite/views/ws/echo.py @@ -0,0 +1,18 @@ +# coding=utf-8 +from pysite.websockets import Websocket + + +class EchoWebsocket(Websocket): +    path = "/echo" +    name = "ws_echo" + +    def on_open(self): +        print("Echo | WS opened.") +        self.send("Hey, welcome!") + +    def on_message(self, message): +        print(f"Echo | Message: {message}") +        self.send(message) + +    def on_close(self): +        print("Echo | WS closed.") diff --git a/pysite/websockets.py b/pysite/websockets.py new file mode 100644 index 00000000..f82217ef --- /dev/null +++ b/pysite/websockets.py @@ -0,0 +1,97 @@ +# coding=utf-8 +from flask import Blueprint + +from geventwebsocket.websocket import WebSocket + + +class Websocket: +    """ +    Base class for representing a Websocket. + +    At minimum, you must implement the `on_message(self, message)` function. Without it, you won't be able to handle +    any messages, and an error will be thrown! + +    If you need access to the database, you can mix-in DBMixin, just like any view class: + +    >>> class DBWebsocket(Websocket, DBMixin): +    ...     name = "db_websocket" +    ...     path = "/db_websocket"  # This will be prefixed with "/ws" by the blueprint +    ...     table = "ws" +    ... +    ...     def on_message(self, message): +    ...         self.send( +    ...             json.loads(self.db.get(self.table_name, message)) +    ...         ) + +    Please note that an instance of this class is created for every websocket connected to the path. This does, however, +    mean that you can store any state required by your websocket. +    """ + +    path = ""  # type: str +    name = ""  # type: str + +    def __init__(self, socket: WebSocket): +        self.socket = socket + +    def on_open(self): +        """ +        Called once when the websocket is opened. Optional. +        """ + +    def on_message(self, message: str): +        """ +        Called when a message is received by the websocket. +        """ + +        raise NotImplementedError() + +    def on_close(self): +        """ +        Called once when the websocket is closed. Optional. +        """ + +    def send(self, message, binary=None): +        """ +        Send a message to the currently-connected websocket, if it's open. + +        Nothing will happen if the websocket is closed. +        """ + +        if not self.socket.closed: +            self.socket.send(message, binary=binary) + +    @classmethod +    def setup(cls: "type(Websocket)", manager: "pysite.route_manager.RouteManager", blueprint: Blueprint): +        """ +        Set up the websocket object, calling `setup()` on any superclasses as necessary (for example, on the DB +        mixin). + +        This function will set up a websocket handler so that it behaves in a class-oriented way. It's up to you to +        deal with message handling yourself, however. +        """ + +        if hasattr(super(), "setup"): +            super().setup(manager, blueprint) + +        if not cls.path or not cls.name: +            raise RuntimeError("Websockets must have both `path` and `name` defined") + +        def handle(socket: WebSocket): +            """ +            Wrap the current Websocket class, dispatching events to it as necessary. We're using gevent, so there's +            no need to worry about blocking here. +            """ + +            ws = cls(socket)  # Instantiate the current class, passing it the WS object + +            ws.on_open()  # Call the "on_open" handler + +            while not socket.closed:  # As long as the socket is open... +                message = socket.receive()  # Wait for a message + +                if not socket.closed:  # If the socket didn't just close (there's always a None message on closing) +                    ws.on_message(message)  # Call the "on_message" handler + +            ws.on_close()  # The socket just closed, call the "on_close" handler + +        blueprint.route(cls.path)(handle)  # Register the handling function to the WS blueprint diff --git a/requirements.txt b/requirements.txt index 205154bb..cc2dc0e9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,3 +6,4 @@ gevent-websocket  wsaccel  ujson  schema +flask_sockets diff --git a/templates/ws_test.html b/templates/ws_test.html index 921226c0..36be61d6 100644 --- a/templates/ws_test.html +++ b/templates/ws_test.html @@ -5,7 +5,7 @@          <h1>Open your JS console to test</h1>          <script type="application/javascript"> -            let ws = new WebSocket("wss://api.{{ server_name }}/ws/echo"); +            let ws = new WebSocket("ws://{{ server_name }}/ws/echo");              ws.onopen = function(event) {                  console.log("WS opened! Use send() to send a message."); diff --git a/ws_app.py b/ws_app.py deleted file mode 100644 index cab43ac0..00000000 --- a/ws_app.py +++ /dev/null @@ -1,28 +0,0 @@ -# coding=utf-8 -import os - -from geventwebsocket import Resource, WebSocketApplication, WebSocketServer - - -class EchoApplication(WebSocketApplication): -    def on_open(self): -        print("Connection opened") - -    def on_message(self, message, **kwargs): -        print(f"<- {message}") -        self.ws.send(message) -        print(f"-> {message}") - -    def on_close(self, reason): -        print(reason) - - -app = WebSocketServer( -    ('', os.environ.get("WS_PORT", 8080)), -    Resource({ -        "/ws/echo": EchoApplication  # Dicts are ordered in Python 3.6 -    }) -) - -if __name__ == "__main__": -    app.serve_forever() | 
