Skip to content

Commit

Permalink
Merge pull request #1207 from amadolid/feature-request/websocket
Browse files Browse the repository at this point in the history
[FEATURE-REQUEST]: Websocket via channels[daphne]
  • Loading branch information
marsninja authored Nov 8, 2023
2 parents 0b37484 + f5ab3bb commit 36b9b89
Show file tree
Hide file tree
Showing 12 changed files with 286 additions and 19 deletions.
11 changes: 9 additions & 2 deletions jaseci_serv/jaseci_serv/asgi.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,16 @@
"""

import os

from .socket.routing import websocket_urlpatterns
from django.core.asgi import get_asgi_application
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.auth import AuthMiddlewareStack

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "jaseci_serv.settings")

application = get_asgi_application()
application = ProtocolTypeRouter(
{
"http": get_asgi_application(),
"websocket": AuthMiddlewareStack(URLRouter(websocket_urlpatterns)),
}
)
3 changes: 2 additions & 1 deletion jaseci_serv/jaseci_serv/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
# Application definition

INSTALLED_APPS = [
"daphne",
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
Expand Down Expand Up @@ -96,7 +97,7 @@
},
]

WSGI_APPLICATION = "jaseci_serv.wsgi.application"
ASGI_APPLICATION = "jaseci_serv.asgi.application"

# Database
# https://docs.djangoproject.com/en/3.0/ref/settings/#databases
Expand Down
107 changes: 107 additions & 0 deletions jaseci_serv/jaseci_serv/socket/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# **HOW TO SETUP `CHANNEL_LAYER`**

## **GLOBAL VARS:** `CHANNEL_LAYER`
### **`IN MEMORY`** *(default)*
```json
{
"BACKEND": "channels.layers.InMemoryChannelLayer"
}
```
- This config will only works with single jaseci instance. Notification from async walkers will also not work

### **`REDIS`**
```json
{
"BACKEND": "channels_redis.core.RedisChannelLayer",
"CONFIG": {
"hosts": [
["localhost", 6379]
]
}
}
```
- This should work on mutiple jaseci instance. Notification from async walker should also work
---
# **`NOTIFICATION FROM JAC`**
## wb.**`notify`**
> **`Arguments`:** \
> **target**: str \
> **data**: dict
>
> **`Return`:** \
> None
>
> **`Usage`:** \
> Send notification from jac to target group
>
> **`Remarks`:** \
> if user is logged in, `target` can be master id without `urn:uuid:` \
> else used thed `session_id` from client connection
##### **`HOW TO TRIGGER`**
```js
wb.notify(target, {"test": 123456});
```

# **WEB SETUP**
```js
// target can be random string or current token
socket = new WebSocket(`ws://${window.location.host}/ws/socket-server/{{target}}`)
socket.onmessage = (event) => {
console.log(event);
// your event handler
data = JSON.parse(event.data);
switch(data.type) {
case "connect":
// code block
break;
case "your-custom-type":
// code block
break;
default:
// code block
}
}
// notify backend
socket.send(JSON.stringify({"message": "test"}))
```
## Example connection via `token`
```js
socket = new WebSocket(`ws://${window.location.host}/ws/socket-server/276a40aec1dffc48a25463c3e2545473b45a663364adf3a2f523b903aa254c9f`)
// if token is valid, it's session will be connected to the user's master jid
// all notification from FE will now be send to user's master jid

socket.onmessage = (event) => {
console.log(event);
}

// all clients that is subscribed to user's master jid will received this notification
socket.send(JSON.stringify({"message": "test"}))
```
#### console.`log`(**event**)
> ![console.log(event)](https://user-images.githubusercontent.com/74129725/267296913-b7b4bdd7-d6c7-49c2-82fe-2d19491daa6c.png "console.log(event)")
#### event.`data`
```txt
{"type": "connect", "authenticated": true, "session_id": null}
```
---
## Example connection **without** `token`
```js
socket = new WebSocket(`ws://${window.location.host}/ws/socket-server/any-ranmdom-string`)
// this socket will be subscribed to a random uuid
// you will need to use that random uuid on wb.notify as target params
// FE is required to send it on walkers with wb.notify to override target

socket.onmessage = (event) => {
console.log(event);
// you may get session_id from JSON.parse(event.data).session_id
}

// not advisable but still can be used for notifying that random uuid
socket.send(JSON.stringify({"message": "test"}))
```
#### console.`log`(**event**)
> ![console.log(event)](https://user-images.githubusercontent.com/74129725/267294795-032a7d78-0124-4db5-bed7-5858e7d72774.png "console.log(event)")
#### event.`data`
```txt
{"type": "connect", "authenticated": false, "session_id": "53a0e05e-8689-4e87-984d-dbd4bad58c9d"}
```
Empty file.
62 changes: 62 additions & 0 deletions jaseci_serv/jaseci_serv/socket/consumer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import os
from uuid import uuid4
from json import loads, dumps

from asgiref.sync import async_to_sync
from channels.layers import settings
from channels.generic.websocket import WebsocketConsumer

from .event_action import authenticated_user
from jaseci_serv.base.models import lookup_global_config


class SocketConsumer(WebsocketConsumer):
def connect(self):
self.accept()
session_id = None
authenticated = False
target = self.scope["url_route"]["kwargs"]["target"]

if target == "anonymous":
self.target = session_id = str(uuid4())
else:
user = authenticated_user(target)
if user:
self.target = user.master.urn[9:]
authenticated = True
else:
self.target = session_id = str(uuid4())

async_to_sync(self.channel_layer.group_add)(self.target, self.channel_name)
self.send(
text_data=dumps(
{
"type": "connect",
"authenticated": authenticated,
"session_id": session_id,
}
)
)

def receive(self, text_data=None, bytes_data=None):
data = loads(text_data)

async_to_sync(self.channel_layer.group_send)(
self.target, {"type": "notify", "data": data}
)

def notify(self, data):
self.send(text_data=dumps(data))


setattr(
settings,
"CHANNEL_LAYERS",
{
"default": loads(
lookup_global_config(
"CHANNEL_LAYER", '{"BACKEND": "channels.layers.InMemoryChannelLayer"}'
)
)
},
)
39 changes: 39 additions & 0 deletions jaseci_serv/jaseci_serv/socket/event_action.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
try:
from hmac import compare_digest
except ImportError:

def compare_digest(a, b):
return a == b


import binascii
from knox.crypto import hash_token
from knox.models import AuthToken
from knox.settings import CONSTANTS
from jaseci.jsorc.live_actions import jaseci_action
from channels.layers import get_channel_layer
from asgiref.sync import async_to_sync
from django.utils import timezone


def authenticated_user(token: str):
for auth_token in AuthToken.objects.filter(
token_key=token[: CONSTANTS.TOKEN_KEY_LENGTH]
):
try:
digest = hash_token(token)
if (
compare_digest(digest, auth_token.digest)
and auth_token.expiry > timezone.now()
):
return auth_token.user
except (TypeError, binascii.Error):
pass
return None


@jaseci_action(act_group=["wb"])
def notify(target: str, data: dict):
async_to_sync(get_channel_layer().group_send)(
target, {"type": "notify", "data": data}
)
6 changes: 6 additions & 0 deletions jaseci_serv/jaseci_serv/socket/routing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from django.urls import path
from . import consumer

websocket_urlpatterns = [
path(r"ws/socket-server/<str:target>", consumer.SocketConsumer.as_asgi())
]
Empty file.
45 changes: 45 additions & 0 deletions jaseci_serv/jaseci_serv/socket/tests/test_websocket.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import pytest
from uuid import UUID
from json import loads
from jaseci.utils.utils import TestCaseHelper
from django.test import TestCase

from channels.routing import URLRouter
from channels.testing import WebsocketCommunicator


class WebSocketTests(TestCaseHelper, TestCase):
"""Test the publicly available node API"""

def setUp(self):
super().setUp()

from ..routing import websocket_urlpatterns

self.app = URLRouter(websocket_urlpatterns)

def tearDown(self):
super().tearDown()

def is_valid_uuid(self, uuid):
try:
_uuid = UUID(uuid, version=4)
except ValueError:
return False
return str(_uuid) == uuid

@pytest.mark.asyncio
async def test_websocket(self):
communicator = WebsocketCommunicator(self.app, "ws/socket-server/anonymous")

connected, subprotocol = await communicator.connect()
self.assertTrue(connected)
response: dict = loads(await communicator.receive_from())
self.assertTrue(self.is_valid_uuid(response.pop("session_id")))
self.assertEqual(response, {"type": "connect", "authenticated": False})

await communicator.send_to(text_data='{"test": true}')
response: dict = loads(await communicator.receive_from())
self.assertEqual(response, {"type": "notify", "data": {"test": True}})

await communicator.disconnect()
16 changes: 0 additions & 16 deletions jaseci_serv/jaseci_serv/wsgi.py

This file was deleted.

2 changes: 2 additions & 0 deletions jaseci_serv/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ def get_ver():
"dj-rest-auth[with_social]",
"django-allauth>=0.52.0",
"tzdata>=2022.7",
"channels[daphne]==4.0.0",
"channels-redis",
],
package_data={
"": [
Expand Down
14 changes: 14 additions & 0 deletions jaseci_serv/templates/examples/social_auth.html
Original file line number Diff line number Diff line change
Expand Up @@ -145,4 +145,18 @@ <h1>Google Identity Services Authorization Token model</h1>
});
{% endif %}
{% endif %}

{% if provider == "google" %}
socket = new WebSocket(`ws://${window.location.host}/ws/socket-server/f8e9b218aff5e16b0b06f458dde7c15af9e77a3feb8b6d7ebbfa3e1da7ae7d63`)
{% else %}
socket = new WebSocket(`ws://${window.location.host}/ws/socket-server/anonymous`)
{% endif %}
socket.onmessage = (event) => {
console.log(event);
}

function notify_server() {
socket.send(JSON.stringify({"message": "test"}))
}

</script>

0 comments on commit 36b9b89

Please sign in to comment.