Repository: sanic-org/sanic

Issue:
Websocket subprotocol support
The _websockets_ library that Sanic utilizes supports passing a list of acceptable websocket subprotocols into their server or client implementations, to be used during the opening handshake ("Sec-WebSocket-Protocol").  Sanic does not support this currently and does not do anything w/ subprotocols in the `websocket_handshake` method.  The eventual websocket object that gets passed to the Sanic handler has an "subprotocol" attribute, which would normally be set during the handshake. Currently it is empty.  Is there any plan to support subprotocols in the future?  Thanks for the excellent framework!


Discussion:
hmm.. seems like `Sanic` is using `WebSocketCommonProtocol`, not `WebSocketServerProtocol`. But `WebSocketCommonProtocol` doesn't support to pass in `subprotocols ` directly (we can hook it up if really needs). @r0fls is there any reason to use `WebSocketCommonProtocol`, instead of `WebSocketServerProtocol` ?

Also , https://github.com/channelcat/sanic/blob/master/sanic/websocket.py#L48 this piece of code is mainly from the implementation of `WebSocketServerProtocol`. 

cc @miguelgrinberg
Yes, the support for negotiating a subprotocol is in the `WebSocketServerProtocol` class, which I think would be harder to integrate with the sanic request object than `WebSocketCommonProtocol`, which only has the low-level WebSocket support.

What I think makes the most sense here is to implement the negotiation of the subprotocol in `websocket_handshake`. We can add a `subprotocols` argument to the `@websocket` decorator so that the application can specify what subprotocols it accepts. Example:

```python
@app.websocket('/feed', subprotocols=['foo', 'bar'])
async def feed(request, ws):
    if ws.subprotocol:
        print('The subprotocol is: ', ws.subprotocol)
    while True:
        data = 'hello!'
        print('Sending: ' + data)
        await ws.send(data)
        data = await ws.recv()
        print('Received: ' + data)
```

Resolving patch:
diff --git a/sanic/app.py b/sanic/app.py
--- a/sanic/app.py
+++ b/sanic/app.py
@@ -214,9 +214,12 @@ def add_route(self, handler, uri, methods=frozenset({'GET'}), host=None,
         return handler
 
     # Decorator
-    def websocket(self, uri, host=None, strict_slashes=False):
+    def websocket(self, uri, host=None, strict_slashes=False,
+                  subprotocols=None):
         """Decorate a function to be registered as a websocket route
         :param uri: path of the URL
+        :param subprotocols: optional list of strings with the supported
+                             subprotocols
         :param host:
         :return: decorated function
         """
@@ -236,7 +239,7 @@ async def websocket_handler(request, *args, **kwargs):
                     # On Python3.5 the Transport classes in asyncio do not
                     # have a get_protocol() method as in uvloop
                     protocol = request.transport._protocol
-                ws = await protocol.websocket_handshake(request)
+                ws = await protocol.websocket_handshake(request, subprotocols)
 
                 # schedule the application handler
                 # its future is kept in self.websocket_tasks in case it
diff --git a/sanic/websocket.py b/sanic/websocket.py
--- a/sanic/websocket.py
+++ b/sanic/websocket.py
@@ -41,7 +41,7 @@ def write_response(self, response):
         else:
             super().write_response(response)
 
-    async def websocket_handshake(self, request):
+    async def websocket_handshake(self, request, subprotocols=None):
         # let the websockets package do the handshake with the client
         headers = []
 
@@ -57,6 +57,17 @@ def set_header(k, v):
         except InvalidHandshake:
             raise InvalidUsage('Invalid websocket request')
 
+        subprotocol = None
+        if subprotocols and 'Sec-Websocket-Protocol' in request.headers:
+            # select a subprotocol
+            client_subprotocols = [p.strip() for p in request.headers[
+                'Sec-Websocket-Protocol'].split(',')]
+            for p in client_subprotocols:
+                if p in subprotocols:
+                    subprotocol = p
+                    set_header('Sec-Websocket-Protocol', subprotocol)
+                    break
+
         # write the 101 response back to the client
         rv = b'HTTP/1.1 101 Switching Protocols\r\n'
         for k, v in headers:
@@ -69,5 +80,6 @@ def set_header(k, v):
             max_size=self.websocket_max_size,
             max_queue=self.websocket_max_queue
         )
+        self.websocket.subprotocol = subprotocol
         self.websocket.connection_made(request.transport)
         return self.websocket


Test patch:
diff --git a/tests/test_routes.py b/tests/test_routes.py
--- a/tests/test_routes.py
+++ b/tests/test_routes.py
@@ -341,6 +341,7 @@ def test_websocket_route():
 
     @app.websocket('/ws')
     async def handler(request, ws):
+        assert ws.subprotocol is None
         ev.set()
 
     request, response = app.test_client.get('/ws', headers={
@@ -352,6 +353,48 @@ async def handler(request, ws):
     assert ev.is_set()
 
 
+def test_websocket_route_with_subprotocols():
+    app = Sanic('test_websocket_route')
+    results = []
+
+    @app.websocket('/ws', subprotocols=['foo', 'bar'])
+    async def handler(request, ws):
+        results.append(ws.subprotocol)
+
+    request, response = app.test_client.get('/ws', headers={
+        'Upgrade': 'websocket',
+        'Connection': 'upgrade',
+        'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==',
+        'Sec-WebSocket-Version': '13',
+        'Sec-WebSocket-Protocol': 'bar'})
+    assert response.status == 101
+
+    request, response = app.test_client.get('/ws', headers={
+        'Upgrade': 'websocket',
+        'Connection': 'upgrade',
+        'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==',
+        'Sec-WebSocket-Version': '13',
+        'Sec-WebSocket-Protocol': 'bar, foo'})
+    assert response.status == 101
+
+    request, response = app.test_client.get('/ws', headers={
+        'Upgrade': 'websocket',
+        'Connection': 'upgrade',
+        'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==',
+        'Sec-WebSocket-Version': '13',
+        'Sec-WebSocket-Protocol': 'baz'})
+    assert response.status == 101
+
+    request, response = app.test_client.get('/ws', headers={
+        'Upgrade': 'websocket',
+        'Connection': 'upgrade',
+        'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==',
+        'Sec-WebSocket-Version': '13'})
+    assert response.status == 101
+    
+    assert results == ['bar', 'bar', None, None]
+
+
 def test_route_duplicate():
     app = Sanic('test_route_duplicate')