Skip to content

Commit

Permalink
Merge 34a9dac into 58f0f2c
Browse files Browse the repository at this point in the history
  • Loading branch information
michael-k committed Sep 3, 2018
2 parents 58f0f2c + 34a9dac commit 914b734
Show file tree
Hide file tree
Showing 12 changed files with 247 additions and 183 deletions.
62 changes: 48 additions & 14 deletions doc/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,8 @@ any user updates, deletes, or creates an object.
Protocol
--------

The rohrpost protocol sits on top of channels_ inside the ``text`` component of
a channels message. rohrpost expects this ``text`` component to be valid JSON
with
The rohrpost protocol sits on top of channels_. rohrpost expects the messages
to be valid JSON with

- An ``id`` field that rohrpost sends back in the response.
- A ``type`` field that contains a string defining the message type (and hence,
Expand Down Expand Up @@ -54,27 +53,27 @@ Installation

From the command line::

pip install https://github.com/user/repository/archive/branch.zip
pip install rohrpost

Or add this line to your `requirements.txt`::

https://github.com/user/repository/archive/branch.zip
rohrpost==2.x


Routing
-------

Once you have installed `rohrpost`, you'll need to add the main `rohrpost`
handler to your `routing.py`. You can find details on this in Channels'
`routing documentation`_.
Once you have installed `rohrpost`, you'll need to add a `rohrpost` consumer
to your `routing.py`. You can find details on this in Channels' `routing
documentation`_.

.. code:: Python
from channels import route
from rohrpost.main import handle_rohrpost_message
from django.urls import path
from rohrpost.sync_consumer import SyncRohrpostConsumer
channel_routing = [
route('websocket.receive', handle_rohrpost_message, path=r'/rohrpost/$'),
websocket_urlpatterns = [
path('ws/rohrpost/', SyncRohrpostConsumer),
]
Expand Down Expand Up @@ -149,7 +148,7 @@ standard handler for this.
Utility functions
-----------------

rohrpost provides three main helper functions for message sending in
rohrpost provides a few helper functions for message sending in
``rohrpost.message``:

- ``rohrpost.message.send_message``
Expand All @@ -166,11 +165,46 @@ rohrpost provides three main helper functions for message sending in
- ``rohrpost.message.send_error`` sends an error message explicitly, takes the
same arguments as ``send_message``.

- ``rohrpost.messages.send_to_group`` sends a message to a specific group.


Migrating from rohrpost v1
--------------------------

- Follow `migration guide`_ from Channels 1 to Channels 2.

- Adjust the routing in your application. What was before

channel_routing = [
route('websocket.receive', handle_rohrpost_message, path=r'^/ws/rohrpost/$'),
]

should now be

websocket_urlpatterns = [
path('ws/rohrpost/', SyncRohrpostConsumer),
]

- Your handlers will no longer receive ``message`` as a first argument, but an
instance of ``channels.generic.websocket.WebsocketConsumer``. You can pass
it directly to the utility functions as before with ``message``.

- To add clients to a group, change ``Group(group_name).add(message.reply_channel)``
to ``consumer.add_to_group(group_name)`` when using
``rohrpost.sync_consumer.SyncRohrpostConsumer``. Otherwise we recommend to
check out our consumer's implementation and read about `Groups in Channels 2`._

- To send messages to a group you can use the new utility function
``rohrpost.messages.send_to_group``.


.. toctree::
:maxdepth: 1
:caption: Contents:

.. _channels: https://github.com/django/channels
.. _Django's: http://djangoproject.com/
.. _rohrpost.js: https://github.com/axsemantics/rohrpost-js
.. _routing documentation: http://channels.readthedocs.io/en/latest/routing.html
.. _routing documentation: https://channels.readthedocs.io/en/latest/topics/routing.html
.. _channels one-to-two: https://channels.readthedocs.io/en/latest/one-to-two.html
.. _Groups in Channels 2: https://channels.readthedocs.io/en/latest/topics/channel_layers.html#groups
4 changes: 2 additions & 2 deletions rohrpost/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@


@rohrpost_handler("ping")
def handle_ping(message, request):
def handle_ping(consumer, request):
"""
Handles requests of this format ("data" being an optional attribute):
{
Expand Down Expand Up @@ -31,4 +31,4 @@ def handle_ping(message, request):
else:
data = {"data": request["data"]}

send_message(message=message, message_id=request["id"], handler="pong", data=data)
send_message(consumer=consumer, message_id=request["id"], handler="pong", data=data)
26 changes: 14 additions & 12 deletions rohrpost/main.py
Original file line number Diff line number Diff line change
@@ -1,32 +1,33 @@
import json
from functools import partial

from . import handlers # noqa
from . import handlers
from .message import send_error
from .registry import HANDLERS

REQUIRED_FIELDS = ["type", "id"]
assert handlers # silence qa

REQUIRED_FIELDS = ["type", "id"]

try:
DECODE_ERRORS = (json.JSONDecodeError, TypeError)
DECODE_ERRORS = (json.JSONDecodeError, TypeError) # type: tuple
except AttributeError:
# Python 3.4 raises a ValueError instead of json.JSONDecodeError
DECODE_ERRORS = (ValueError, TypeError)


def handle_rohrpost_message(message):
def handle_rohrpost_message(consumer, text_data: str) -> None:
"""
Handling of a rohrpost message will validate the required format:
A valid JSON object including at least an "id" and "type" field.
It then hands off further handling to the registered handler (if any).
"""
_send_error = partial(send_error, message=message, message_id=None, handler=None)
if not message.content["text"]:
_send_error = partial(send_error, consumer=consumer, message_id=None, handler=None)
if not text_data:
return _send_error(error="Received empty message.")

try:
request = json.loads(message.content["text"])
request = json.loads(text_data)
except DECODE_ERRORS as e:
return _send_error(
error="Could not decode JSON message. Error: {}".format(str(e))
Expand All @@ -39,12 +40,13 @@ def handle_rohrpost_message(message):
if field not in request:
return _send_error(error="Missing required field '{}'.".format(field))

if not request["type"] in HANDLERS:
request_type = request["type"]
if request_type not in HANDLERS:
return send_error(
message=message,
consumer=consumer,
message_id=request["id"],
handler=request["type"],
error="Unknown message type '{}'.".format(request["type"]),
handler=request_type,
error="Unknown message type '{}'.".format(request_type),
)

HANDLERS[request["type"]](message, request)
HANDLERS[request_type](consumer=consumer, request=request)
44 changes: 34 additions & 10 deletions rohrpost/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@
import random
from decimal import Decimal

try:
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer

channel_layer = get_channel_layer()

except ImportError:
print("Channels is not installed, running in test mode!")
async_to_sync = None


class TolerantJSONEncoder(json.JSONEncoder):
def default(self, obj):
Expand All @@ -14,10 +24,20 @@ def default(self, obj):
return json.JSONDecoder.default(self, obj)


def _send_message(*, message, content: dict, close: bool):
message.reply_channel.send(
{"text": json.dumps(content, cls=TolerantJSONEncoder), "close": close}
)
def send_to_group(group_name, message) -> None:
"""Send a message to a group.
This requires the group to be exist on the default channel layer.
"""
if async_to_sync is None:
return
async_to_sync(channel_layer.send)(group_name, message)


def _send_message(*, consumer, content: dict, close: bool):
consumer.send(json.dumps(content, cls=TolerantJSONEncoder))
if close is True:
consumer.close()


def build_message(
Expand All @@ -39,18 +59,18 @@ def build_message(


def send_message(
*, message, handler, message_id=None, close=False, error=None, data: dict = None
*, consumer, handler, message_id=None, close=False, error=None, data: dict = None
):
content = build_message(
handler=handler, message_id=message_id, error=error, data=data
)

if not content:
raise Exception("Cannot send an empty message.")
_send_message(message=message, content=content, close=close)
_send_message(consumer=consumer, content=content, close=close)


def send_success(*, message, handler, message_id, close=False, data: dict = None):
def send_success(*, consumer, handler, message_id, close=False, data: dict = None):
"""
This method directly wraps send_message but checks the existence of id and type.
"""
Expand All @@ -60,16 +80,20 @@ def send_success(*, message, handler, message_id, close=False, data: dict = None
)

send_message(
message=message, message_id=message_id, handler=handler, close=close, data=data
consumer=consumer,
message_id=message_id,
handler=handler,
close=close,
data=data,
)


def send_error(*, message, handler, message_id, error, close=False, data: dict = None):
def send_error(*, consumer, handler, message_id, error, close=False, data: dict = None):
"""
This method wraps send_message and makes sure that error is a keyword argument.
"""
send_message(
message=message,
consumer=consumer,
message_id=message_id,
handler=handler,
error=error,
Expand Down
26 changes: 10 additions & 16 deletions rohrpost/mixins.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,6 @@
import json

from .message import TolerantJSONEncoder, build_message

try:
from channels import Group
except ImportError:
Group = None
print("Channels is not installed, running in test mode!")
from .message import TolerantJSONEncoder, build_message, send_to_group

try:
from django.db.transaction import on_commit as on_transaction_commit
Expand Down Expand Up @@ -61,16 +55,16 @@ def _send_notify(
if not set(updated_fields) & set(message_data["object"].keys()):
return

payload = {
"text": json.dumps(
build_message(
generate_id=True, handler="subscription-update", data=message_data
),
cls=self.encoder,
)
}
payload = json.dumps(
build_message(
generate_id=True, handler="subscription-update", data=message_data
),
cls=self.encoder,
)

on_transaction_commit(lambda: Group(group_name).send(payload))
on_transaction_commit(
lambda: send_to_group(group_name=group_name, message=payload)
)


class NotifyOnCreate(NotifyBase):
Expand Down
21 changes: 21 additions & 0 deletions rohrpost/sync_consumer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from asgiref.sync import async_to_sync
from channels.generic.websocket import WebsocketConsumer

from .main import handle_rohrpost_message


class SyncRohrpostConsumer(WebsocketConsumer):
def connect(self):
self.accept()

def disconnect(self, close_code):
pass

def receive(self, text_data: str) -> None:
handle_rohrpost_message(consumer=self, text_data=text_data)

def add_to_group(self, group_name) -> None:
async_to_sync(self.channel_layer.group_add)(group_name, self.channel_name)

def remove_from_group(self, group_name) -> None:
async_to_sync(self.channel_layer.group_discard)(group_name, self.channel_name)
13 changes: 5 additions & 8 deletions rohrpost/tests.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,14 @@
import json


class ReplyChannel:
class Consumer:
def __init__(self):
self.data = []
self.closed = False

def send(self, message_dict):
def send(self, message: str):
if not self.closed:
self.data.append(json.loads(message_dict.get("text")))
self.closed = message_dict.get("close", False)
self.data.append(json.loads(message))


class Message:
def __init__(self):
self.reply_channel = ReplyChannel()
def close(self):
self.closed = True
6 changes: 3 additions & 3 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import pytest

from rohrpost.mixins import NotifyOnChange
from rohrpost.tests import Message
from rohrpost.tests import Consumer


@pytest.fixture
def message():
return Message()
def consumer():
return Consumer()


class MockModel:
Expand Down

0 comments on commit 914b734

Please sign in to comment.