├── .gitignore ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── README.rst ├── channels_framework ├── __init__.py ├── bindings.py ├── mixins.py ├── settings.py ├── templates │ └── debugger │ │ └── index.html └── urls.py ├── requirements.txt ├── runtests.py ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models.py ├── test_bindings.py └── test_settings.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | 55 | # Sphinx documentation 56 | docs/_build/ 57 | 58 | # PyBuilder 59 | target/ 60 | 61 | #Ipython Notebook 62 | .ipynb_checkpoints 63 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: python 3 | python: 4 | - "3.4" 5 | - "3.5" 6 | install: pip install tox-travis 7 | script: tox 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (c) 2016, Samuel Bolgert 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 17 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 18 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 20 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 21 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 22 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 24 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 25 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.rst 3 | recursive-include channels_api/templates * 4 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Note 2 | ----------- 3 | This repo is cloned from `channels-api `__ 4 | 5 | Channels API 6 | ------------ 7 | 8 | .. image:: https://travis-ci.org/linuxlewis/channels-api.svg?branch=master 9 | :target: https://travis-ci.org/linuxlewis/channels-api 10 | 11 | Channels API exposes a RESTful Streaming API over WebSockets using 12 | channels. It provides a ``ResourceBinding`` which is comparable to Django 13 | Rest Framework's ``ModelViewSet``. It is based on DRF serializer 14 | classes. 15 | 16 | It requires Python 3, Django 1.8, and Django Rest Framework 3.0 17 | 18 | Table of Contents 19 | ----------------- 20 | 21 | - `Getting Started <#getting-started>`__ 22 | - `ResourceBinding <#resourcebinding>`__ 23 | - `Subscriptions <#subscriptions>`__ 24 | - `Errors <#errors>`__ 25 | - `Roadmap <#roadmap>`__ 26 | 27 | 28 | How does it work? 29 | ----------------- 30 | 31 | The API builds on top of channels' ``WebsocketBinding`` class. It works by having 32 | the client send a ``stream`` and ``payload`` parameters. This allows 33 | us to route messages to different streams (or resources) for a particular 34 | action. So ``POST /user`` would have a message that looks like the following 35 | 36 | .. code:: javascript 37 | 38 | var msg = { 39 | stream: "users", 40 | payload: { 41 | action: "create", 42 | data: { 43 | email: "test@example.com", 44 | password: "password", 45 | } 46 | } 47 | } 48 | 49 | ws.send(JSON.stringify(msg)) 50 | 51 | Why? 52 | ---- 53 | 54 | You're already using Django Rest Framework and want to expose similar 55 | logic over WebSockets. 56 | 57 | WebSockets can publish updates to clients without a request. This is 58 | helpful when a resource can be edited by multiple users across many platforms. 59 | 60 | Getting Started 61 | --------------- 62 | 63 | This tutorial assumes you're familiar with channels and have completed 64 | the `Getting 65 | Started `__ 66 | 67 | - Add ``channelsrestframework`` to requirements.txt 68 | 69 | .. code:: bash 70 | 71 | pip install channelsrestframework 72 | 73 | - Add ``channels_framework`` to ``INSTALLED_APPS`` 74 | 75 | .. code:: python 76 | 77 | 78 | INSTALLED_APPS = ( 79 | 'rest_framework', 80 | 'channels', 81 | 'channels_framework' 82 | ) 83 | 84 | - Add a ``WebsocketDemultiplexer`` to your ``channel_routing`` 85 | 86 | .. code:: python 87 | 88 | # proj/routing.py 89 | 90 | 91 | from channels.generic.websockets import WebsocketDemultiplexer 92 | from channels.routing import route_class 93 | 94 | class APIDemultiplexer(WebsocketDemultiplexer): 95 | 96 | mapping = { 97 | 'questions': 'questions_channel' 98 | } 99 | 100 | channel_routing = [ 101 | route_class(APIDemultiplexer) 102 | ] 103 | 104 | - Add your first resource binding 105 | 106 | .. code:: python 107 | 108 | 109 | # polls/bindings.py 110 | 111 | from channels_framework.bindings import ResourceBinding 112 | 113 | from .models import Question 114 | from .serializers import QuestionSerializer 115 | 116 | class QuestionBinding(ResourceBinding): 117 | 118 | model = Question 119 | stream = "questions" 120 | serializer_class = QuestionSerializer 121 | queryset = Question.objects.all() 122 | 123 | 124 | # proj/routing.py 125 | 126 | from channels.routing import route_class, route 127 | 128 | from polls.bindings import QuestionBinding 129 | 130 | channel_routing = [ 131 | route_class(APIDemultiplexer), 132 | route("question_channel", QuestionBinding.consumer) 133 | ] 134 | 135 | That's it. You can now make REST WebSocket requests to the server. 136 | 137 | .. code:: javascript 138 | 139 | var ws = new WebSocket("ws://" + window.location.host + "/") 140 | 141 | ws.onmessage = function(e){ 142 | console.log(e.data) 143 | } 144 | 145 | var msg = { 146 | stream: "questions", 147 | payload: { 148 | action: "create", 149 | data: { 150 | question_text: "What is your favorite python package?" 151 | }, 152 | request_id: "some-guid" 153 | } 154 | } 155 | ws.send(JSON.stringify(msg)) 156 | // response 157 | { 158 | stream: "questions", 159 | payload: { 160 | action: "create", 161 | data: { 162 | id: "1", 163 | question_text: "What is your favorite python package" 164 | } 165 | errors: [], 166 | response_status: 200 167 | request_id: "some-guid" 168 | } 169 | } 170 | 171 | - Add the channels debugger page (Optional) 172 | 173 | This page is helpful to debug API requests from the browser and see the 174 | response. It is only designed to be used when ``DEBUG=TRUE``. 175 | 176 | .. code:: python 177 | 178 | # proj/urls.py 179 | 180 | from django.conf.urls import include 181 | 182 | urlpatterns = [ 183 | url(r'^channels-api/', include('channels_framework.urls')) 184 | ] 185 | 186 | ResourceBinding 187 | --------------- 188 | 189 | By default the ``ResourceBinding`` implements the following REST methods: 190 | 191 | - ``create`` 192 | - ``retrieve`` 193 | - ``update`` 194 | - ``list`` 195 | - ``delete`` 196 | - ``subscribe`` 197 | 198 | See the test suite for usage examples for each method. 199 | 200 | 201 | List Pagination 202 | --------------- 203 | 204 | Pagination is handled by `django.core.paginator.Paginator` 205 | 206 | You can configure the ``DEFAULT_PAGE_SIZE`` by overriding the settings. 207 | 208 | 209 | .. code:: python 210 | 211 | # settings.py 212 | 213 | channels_framework = { 214 | 'DEFAULT_PAGE_SIZE': 25 215 | } 216 | 217 | 218 | Subscriptions 219 | ------------- 220 | 221 | Subscriptions are a way to programmatically receive updates 222 | from the server whenever a resource is created, updated, or deleted 223 | 224 | By default channels-api has implemented the following subscriptions 225 | 226 | - create a Resource 227 | - update any Resource 228 | - update this Resource 229 | - delete any Resource 230 | - delete this Resource 231 | 232 | To subscribe to a particular event just use the subscribe action 233 | with the parameters to filter 234 | 235 | .. code:: javascript 236 | 237 | // get an event when any question is updated 238 | 239 | var msg = { 240 | stream: "questions", 241 | payload: { 242 | action: "subscribe", 243 | data: { 244 | action: "update" 245 | } 246 | } 247 | } 248 | 249 | // get an event when question(1) is updated 250 | var msg = { 251 | stream: "questions", 252 | payload: { 253 | action: "subscribe" 254 | data: { 255 | action: "update", 256 | pk: "1" 257 | } 258 | } 259 | } 260 | -------------------------------------------------------------------------------- /channels_framework/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.2.2" 2 | -------------------------------------------------------------------------------- /channels_framework/bindings.py: -------------------------------------------------------------------------------- 1 | from channels.binding import websockets 2 | from django.core.exceptions import ObjectDoesNotExist 3 | 4 | from rest_framework.exceptions import APIException, NotFound 5 | 6 | from .mixins import SerializerMixin, SubscribeModelMixin, \ 7 | CreateModelMixin, UpdateModelMixin, \ 8 | RetrieveModelMixin, ListModelMixin, DeleteModelMixin 9 | 10 | 11 | class ResourceBindingBase(SerializerMixin, websockets.WebsocketBinding): 12 | 13 | available_actions = ('create', 'retrieve', 'list', 14 | 'update', 'delete', 'subscribe') 15 | fields = [] # hack to pass cls.register() without ValueError 16 | queryset = None 17 | # mark as abstract 18 | model = None 19 | serializer_class = None 20 | lookup_field = 'pk' 21 | 22 | def deserialize(self, message): 23 | self.request_id = message.get('request_id', None) 24 | return super().deserialize(message) 25 | 26 | def group_names(self, instance, action): 27 | groups = [self._group_name(action)] 28 | if instance.id: 29 | groups.append(self._group_name(action, id=instance.id)) 30 | return groups 31 | 32 | def _group_name(self, action, id=None): 33 | """Formatting helper for group names.""" 34 | if id: 35 | return "{}-{}-{}".format(self.model_label, action, id) 36 | else: 37 | return "{}-{}".format(self.model_label, action) 38 | 39 | def has_permission(self, action, pk, data): 40 | return True 41 | 42 | def filter_queryset(self, queryset): 43 | return queryset 44 | 45 | def _format_errors(self, errors): 46 | if isinstance(errors, list): 47 | return errors 48 | elif isinstance(errors, str): 49 | return [errors] 50 | elif isinstance(errors, dict): 51 | return [errors] 52 | 53 | def get_object(self, pk): 54 | queryset = self.filter_queryset(self.get_queryset()) 55 | return queryset.get(**{self.lookup_field: pk}) 56 | 57 | def get_object_or_404(self, pk): 58 | try: 59 | return self.get_object(pk) 60 | except ObjectDoesNotExist: 61 | raise NotFound 62 | 63 | def get_queryset(self): 64 | assert self.queryset is not None, ( 65 | "'%s' should either include a `queryset` attribute, " 66 | "or override the `get_queryset()` method." 67 | % self.__class__.__name__ 68 | ) 69 | return self.queryset.all() 70 | 71 | def run_action(self, action, pk, data): 72 | try: 73 | if not self.has_permission(self.user, action, pk): 74 | self.reply(action, errors=['Permission Denied'], status=401) 75 | if not action in self.available_actions: 76 | self.reply(action, errors=['Invalid Action'], status=400) 77 | elif action in ('create', 'list'): 78 | data, status = getattr(self, action)(data) 79 | elif action in ('retrieve', 'delete'): 80 | data, status = getattr(self, action)(pk) 81 | elif action in ('update', 'subscribe'): 82 | data, status = getattr(self, action)(pk, data) 83 | self.reply(action, data=data, status=status, 84 | request_id=self.request_id) 85 | except APIException as ex: 86 | self.reply(action, errors=self._format_errors(ex.detail), 87 | status=ex.status_code, request_id=self.request_id) 88 | 89 | def reply(self, action, data=None, errors=[], status=200, request_id=None): 90 | """ 91 | Helper method to send a encoded 92 | response to the message's reply_channel. 93 | """ 94 | payload = { 95 | 'errors': errors, 96 | 'data': data, 97 | 'action': action, 98 | 'response_status': status, 99 | 'request_id': request_id 100 | } 101 | return self.message.reply_channel.send( 102 | self.encode(self.stream, payload)) 103 | 104 | 105 | class ResourceBinding(CreateModelMixin, RetrieveModelMixin, ListModelMixin, 106 | UpdateModelMixin, DeleteModelMixin, 107 | SubscribeModelMixin, ResourceBindingBase): 108 | 109 | # mark as abstract 110 | model = None 111 | 112 | 113 | class ReadOnlyResourceBinding(RetrieveModelMixin, ListModelMixin, 114 | ResourceBindingBase): 115 | 116 | # mark as abstract 117 | model = None 118 | -------------------------------------------------------------------------------- /channels_framework/mixins.py: -------------------------------------------------------------------------------- 1 | from channels import Group 2 | from django.core.paginator import Paginator 3 | from rest_framework.exceptions import ValidationError 4 | 5 | from .settings import api_settings 6 | 7 | 8 | class CreateModelMixin(object): 9 | """Mixin class that handles the creation of an object using a DRF serializer.""" 10 | 11 | def create(self, data, **kwargs): 12 | serializer = self.get_serializer(data=data) 13 | serializer.is_valid(raise_exception=True) 14 | self.perform_create(serializer) 15 | return serializer.data, 201 16 | 17 | def perform_create(self, serializer): 18 | serializer.save() 19 | 20 | 21 | class RetrieveModelMixin(object): 22 | 23 | def retrieve(self, pk, **kwargs): 24 | instance = self.get_object_or_404(pk) 25 | serializer = self.get_serializer(instance) 26 | return serializer.data, 200 27 | 28 | 29 | class ListModelMixin(object): 30 | 31 | def list(self, data, **kwargs): 32 | if not data: 33 | data = {} 34 | queryset = self.filter_queryset(self.get_queryset()) 35 | paginator = Paginator(queryset, api_settings.DEFAULT_PAGE_SIZE) 36 | data = paginator.page(data.get('page', 1)) 37 | serializer = self.get_serializer(data, many=True) 38 | return serializer.data, 200 39 | 40 | 41 | class UpdateModelMixin(object): 42 | 43 | def update(self, pk, data, **kwargs): 44 | instance = self.get_object_or_404(pk) 45 | serializer = self.get_serializer(instance, data=data) 46 | serializer.is_valid(raise_exception=True) 47 | self.perform_update(serializer) 48 | return serializer.data, 200 49 | 50 | def perform_update(self, serializer): 51 | serializer.save() 52 | 53 | 54 | class DeleteModelMixin(object): 55 | 56 | def delete(self, pk, **kwargs): 57 | instance = self.get_object_or_404(pk) 58 | self.perform_delete(instance) 59 | return dict(), 200 60 | 61 | def perform_delete(self, instance): 62 | instance.delete() 63 | 64 | 65 | class SubscribeModelMixin(object): 66 | 67 | def subscribe(self, pk, data, **kwargs): 68 | if 'action' not in data: 69 | raise ValidationError('action required') 70 | group_name = self._group_name(data['action'], id=pk) 71 | Group(group_name).add(self.message.reply_channel) 72 | return {}, 200 73 | 74 | 75 | class SerializerMixin(object): 76 | """Mixin class that handles the loading of the serializer class, context and object.""" 77 | 78 | serializer_class = None 79 | 80 | def get_serializer(self, *args, **kwargs): 81 | serializer_class = self.get_serializer_class() 82 | kwargs['context'] = self.get_serializer_context() 83 | return serializer_class(*args, **kwargs) 84 | 85 | def get_serializer_class(self): 86 | assert self.serializer_class is not None, ( 87 | "'%s' should either include a `serializer_class` attribute, " 88 | "or override the `get_serializer_class()` method." 89 | % self.__class__.__name__ 90 | ) 91 | return self.serializer_class 92 | 93 | def get_serializer_context(self): 94 | return { 95 | } 96 | 97 | def serialize_data(self, instance): 98 | return self.get_serializer(instance).data 99 | -------------------------------------------------------------------------------- /channels_framework/settings.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | from rest_framework.settings import APISettings 4 | 5 | DEFAULTS = { 6 | 'DEFAULT_PAGE_SIZE': 25 7 | } 8 | IMPORT_STRINGS = ( 9 | ) 10 | 11 | api_settings = APISettings(getattr(settings, 'CHANNELS_API', None), DEFAULTS, IMPORT_STRINGS) 12 | -------------------------------------------------------------------------------- /channels_framework/templates/debugger/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 18 | 19 | 20 |

Websocket Debugger

21 |

Open console to use the connection stored at ws

22 |

Response Log

23 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /channels_framework/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | from django.views.generic import TemplateView 3 | 4 | urlpatterns = [ 5 | url('', TemplateView.as_view(template_name='debugger/index.html')) 6 | ] 7 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | django 2 | djangorestframework 3 | channels 4 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # https://docs.djangoproject.com/ja/1.9/topics/testing/advanced/#using-the-django-test-runner-to-test-reusable-applications 3 | import os 4 | import sys 5 | 6 | import django 7 | from django.conf import settings 8 | from django.test.utils import get_runner 9 | 10 | if __name__ == "__main__": 11 | os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.test_settings' 12 | django.setup() 13 | TestRunner = get_runner(settings) 14 | test_runner = TestRunner() 15 | failures = test_runner.run_tests(['tests']) 16 | sys.exit(bool(failures)) 17 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import find_packages, setup 2 | from channels_api import __version__ 3 | 4 | setup( 5 | name='channelsrestframework', 6 | version=__version__, 7 | url='https://github.com/madra/channels-rest-framework', 8 | author='Madra David', 9 | author_email='david@madradavid.com', 10 | download_url='https://github.com/madra/channels-rest-framework/tarball/0.1', 11 | description="Build a RESTful API on top of WebSockets using Django channels and Django Rest Framework.", 12 | license='BSD', 13 | packages=find_packages(), 14 | include_package_data=True, 15 | install_requires=[ 16 | 'Django>=1.8', 17 | 'channels>=0.14', 18 | 'djangorestframework>=3.0' 19 | ], 20 | classifiers=[ 21 | 'Development Status :: 5 - Production/Stable', 22 | 'Environment :: Web Environment', 23 | 'Framework :: Django', 24 | 'Intended Audience :: Developers', 25 | 'License :: OSI Approved :: BSD License', 26 | 'Operating System :: OS Independent', 27 | 'Programming Language :: Python', 28 | 'Programming Language :: Python :: 3', 29 | 'Topic :: Internet :: WWW/HTTP', 30 | ] 31 | ) 32 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madra/channels-rest-framework/89bf75cf220a6498eaf9c9771425586e6dce7b89/tests/__init__.py -------------------------------------------------------------------------------- /tests/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.4 on 2016-06-01 17:46 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='TestModel', 18 | fields=[ 19 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 20 | ('name', models.CharField(max_length=255)), 21 | ], 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /tests/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madra/channels-rest-framework/89bf75cf220a6498eaf9c9771425586e6dce7b89/tests/migrations/__init__.py -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class TestModel(models.Model): 5 | """Simple model to test with.""" 6 | 7 | name = models.CharField(max_length=255) 8 | -------------------------------------------------------------------------------- /tests/test_bindings.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from rest_framework import serializers 4 | from rest_framework.exceptions import ValidationError 5 | 6 | from channels import route, Group 7 | from channels.tests import ChannelTestCase, Client, apply_routes 8 | 9 | from channels_api import bindings 10 | from channels_api.settings import api_settings 11 | 12 | from .models import TestModel 13 | 14 | 15 | class TestModelSerializer(serializers.ModelSerializer): 16 | class Meta: 17 | model = TestModel 18 | fields = ('id', 'name') 19 | 20 | 21 | class TestModelResourceBinding(bindings.ResourceBinding): 22 | 23 | model = TestModel 24 | queryset = TestModel.objects.all() 25 | serializer_class = TestModelSerializer 26 | stream = 'testmodel' 27 | 28 | 29 | class ResourceBindingTestCase(ChannelTestCase): 30 | 31 | def setUp(self): 32 | super().setUp() 33 | self.client = Client() 34 | 35 | def _send_and_consume(self, channel, data): 36 | """Helper that sends and consumes message and returns the next message.""" 37 | self.client.send_and_consume(channel, data) 38 | return self._get_next_message() 39 | 40 | def _get_next_message(self): 41 | msg = self.client.get_next_message(self.client.reply_channel) 42 | return json.loads(msg['text']) 43 | 44 | def test_create(self): 45 | """Integration that asserts routing a message to the create channel. 46 | 47 | Asserts response is correct and an object is created. 48 | """ 49 | with apply_routes([route(TestModelResourceBinding.stream, TestModelResourceBinding.consumer)]): 50 | json_content = self._send_and_consume(TestModelResourceBinding.stream, { 51 | 'action': 'create', 52 | 'pk': None, 53 | 'request_id': 'client-request-id', 54 | 'data': {'name': 'some-thing'}}) 55 | 56 | # it should create an object 57 | self.assertEqual(TestModel.objects.count(), 1) 58 | 59 | expected = { 60 | 'action': 'create', 61 | 'data': TestModelSerializer(TestModel.objects.first()).data, 62 | 'errors': [], 63 | 'request_id': 'client-request-id', 64 | 'response_status': 201 65 | } 66 | # it should respond with the serializer.data 67 | self.assertEqual(json_content['payload'], expected) 68 | 69 | def test_create_failure(self): 70 | """Integration that asserts error handling of a message to the create channel.""" 71 | 72 | with apply_routes([route(TestModelResourceBinding.stream, TestModelResourceBinding.consumer)]): 73 | json_content = self._send_and_consume('testmodel', { 74 | 'action': 'create', 75 | 'pk': None, 76 | 'request_id': 'client-request-id', 77 | 'data': {}, 78 | }) 79 | # it should not create an object 80 | self.assertEqual(TestModel.objects.count(), 0) 81 | 82 | expected = { 83 | 'action': 'create', 84 | 'data': None, 85 | 'request_id': 'client-request-id', 86 | 'errors': [{'name': ['This field is required.']}], 87 | 'response_status': 400 88 | } 89 | # it should respond with an error 90 | self.assertEqual(json_content['payload'], expected) 91 | 92 | def test_delete(self): 93 | 94 | with apply_routes([route(TestModelResourceBinding.stream, TestModelResourceBinding.consumer)]): 95 | 96 | instance = TestModel.objects.create(name='test-name') 97 | 98 | json_content = self._send_and_consume('testmodel', { 99 | 'action': 'delete', 100 | 'pk': instance.id, 101 | 'request_id': 'client-request-id', 102 | }) 103 | 104 | expected = { 105 | 'action': 'delete', 106 | 'errors': [], 107 | 'data': {}, 108 | 'request_id': 'client-request-id', 109 | 'response_status': 200 110 | } 111 | self.assertEqual(json_content['payload'], expected) 112 | self.assertEqual(TestModel.objects.count(), 0) 113 | 114 | def test_delete_failure(self): 115 | with apply_routes([route(TestModelResourceBinding.stream, TestModelResourceBinding.consumer)]): 116 | 117 | json_content = self._send_and_consume('testmodel', { 118 | 'action': 'delete', 119 | 'pk': -1, 120 | 'request_id': 'client-request-id' 121 | }) 122 | 123 | expected = { 124 | 'action': 'delete', 125 | 'errors': ['Not found.'], 126 | 'data': None, 127 | 'request_id': 'client-request-id', 128 | 'response_status': 404 129 | } 130 | 131 | self.assertEqual(json_content['payload'], expected) 132 | 133 | def test_list(self): 134 | 135 | with apply_routes([route(TestModelResourceBinding.stream, TestModelResourceBinding.consumer)]): 136 | 137 | for n in range(api_settings.DEFAULT_PAGE_SIZE + 1): 138 | TestModel.objects.create(name='Name-{}'.format(str(n))) 139 | 140 | json_content = self._send_and_consume('testmodel', { 141 | 'action': 'list', 142 | 'request_id': 'client-request-id', 143 | 'data': None, 144 | }) 145 | 146 | self.assertEqual(len(json_content['payload']['data']), api_settings.DEFAULT_PAGE_SIZE) 147 | 148 | json_content = self._send_and_consume('testmodel', { 149 | 'action': 'list', 150 | 'request_id': 'client-request-id', 151 | 'data': { 152 | 'page': 2 153 | } 154 | }) 155 | 156 | self.assertEqual(len(json_content['payload']['data']), 1) 157 | self.assertEqual('client-request-id', json_content['payload']['request_id']) 158 | 159 | def test_retrieve(self): 160 | 161 | with apply_routes([route(TestModelResourceBinding.stream, TestModelResourceBinding.consumer)]): 162 | instance = TestModel.objects.create(name="Test") 163 | 164 | json_content = self._send_and_consume('testmodel', { 165 | 'action': 'retrieve', 166 | 'pk': instance.id, 167 | 'request_id': 'client-request-id' 168 | }) 169 | expected = { 170 | 'action': 'retrieve', 171 | 'data': TestModelSerializer(instance).data, 172 | 'errors': [], 173 | 'response_status': 200, 174 | 'request_id': 'client-request-id' 175 | } 176 | self.assertTrue(json_content['payload'] == expected) 177 | 178 | def test_retrieve_404(self): 179 | with apply_routes([route(TestModelResourceBinding.stream, TestModelResourceBinding.consumer)]): 180 | 181 | json_content = self._send_and_consume('testmodel', { 182 | 'action': 'retrieve', 183 | 'pk': 1, 184 | 'request_id': 'client-request-id' 185 | }) 186 | expected = { 187 | 'action': 'retrieve', 188 | 'data': None, 189 | 'errors': ['Not found.'], 190 | 'response_status': 404, 191 | 'request_id': 'client-request-id' 192 | } 193 | self.assertEqual(json_content['payload'], expected) 194 | 195 | def test_subscribe(self): 196 | 197 | with apply_routes([route(TestModelResourceBinding.stream, TestModelResourceBinding.consumer)]): 198 | 199 | json_content = self._send_and_consume('testmodel', { 200 | 'action': 'subscribe', 201 | 'data': { 202 | 'action': 'create' 203 | }, 204 | 'request_id': 'client-request-id' 205 | }) 206 | 207 | expected_response = { 208 | 'action': 'subscribe', 209 | 'request_id': 'client-request-id', 210 | 'data': {}, 211 | 'errors': [], 212 | 'response_status': 200 213 | } 214 | 215 | self.assertEqual(json_content['payload'], expected_response) 216 | 217 | # it should be on the create group 218 | instance = TestModel.objects.create(name='test-name') 219 | 220 | expected = { 221 | 'action': 'create', 222 | 'data': TestModelSerializer(instance).data, 223 | 'model': 'tests.testmodel', 224 | 'pk': instance.id 225 | } 226 | actual = self._get_next_message() 227 | 228 | self.assertEqual(expected, actual['payload']) 229 | 230 | def test_subscribe_failure(self): 231 | 232 | with apply_routes([route(TestModelResourceBinding.stream, TestModelResourceBinding.consumer)]): 233 | 234 | json_content = self._send_and_consume('testmodel', { 235 | 'action': 'subscribe', 236 | 'data': { 237 | }, 238 | 'request_id': 'client-request-id' 239 | }) 240 | 241 | expected = { 242 | 'action': 'subscribe', 243 | 'data': None, 244 | 'errors': ['action required'], 245 | 'request_id': 'client-request-id', 246 | 'response_status': 400 247 | } 248 | self.assertEqual(expected, json_content['payload']) 249 | 250 | def test_update(self): 251 | with apply_routes([route(TestModelResourceBinding.stream, TestModelResourceBinding.consumer)]): 252 | instance = TestModel.objects.create(name='some-test') 253 | 254 | json_content = self._send_and_consume('testmodel', { 255 | 'action': 'update', 256 | 'pk': instance.id, 257 | 'data': {'name': 'some-value'}, 258 | 'request_id': 'client-request-id' 259 | }) 260 | 261 | instance.refresh_from_db() 262 | 263 | expected = { 264 | 'action': 'update', 265 | 'errors': [], 266 | 'data': TestModelSerializer(instance).data, 267 | 'response_status': 200, 268 | 'request_id': 'client-request-id' 269 | } 270 | 271 | self.assertEqual(json_content['payload'], expected) 272 | 273 | def test_update_failure(self): 274 | with apply_routes([route(TestModelResourceBinding.stream, TestModelResourceBinding.consumer)]): 275 | instance = TestModel.objects.create(name='some-test') 276 | 277 | json_content = self._send_and_consume('testmodel', { 278 | 'action': 'update', 279 | 'pk': -1, 280 | 'data': {'name': 'some-value'}, 281 | 'request_id': 'client-request-id' 282 | }) 283 | 284 | expected = { 285 | 'data': None, 286 | 'action': 'update', 287 | 'errors': ['Not found.'], 288 | 'response_status': 404, 289 | 'request_id': 'client-request-id' 290 | } 291 | 292 | self.assertEqual(json_content['payload'], expected) 293 | -------------------------------------------------------------------------------- /tests/test_settings.py: -------------------------------------------------------------------------------- 1 | SECRET_KEY = 'dog' 2 | 3 | DATABASES = { 4 | 'default': { 5 | 'ENGINE': 'django.db.backends.sqlite3', 6 | } 7 | } 8 | 9 | CHANNEL_LAYERS = { 10 | 'default': { 11 | 'BACKEND': 'asgiref.inmemory.ChannelLayer', 12 | 'ROUTING': [], 13 | }, 14 | } 15 | 16 | MIDDLEWARE_CLASSES = [] 17 | 18 | INSTALLED_APPS = ( 19 | 'django.contrib.auth', 20 | 'django.contrib.contenttypes', 21 | 'django.contrib.sessions', 22 | 'channels', 23 | 'tests' 24 | ) 25 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | {py34}-django-{18,19,110} 4 | {py35}-django-{18,19,110} 5 | 6 | [testenv] 7 | setenv = 8 | PYTHONPATH = {toxinidir}:{toxinidir} 9 | deps = 10 | channels 11 | djangorestframework 12 | django-18: Django>=1.8,<1.9 13 | django-19: Django>=1.9,<1.10 14 | django-110: Django>=1.10 15 | commands = 16 | django: {envpython} {toxinidir}/runtests.py 17 | --------------------------------------------------------------------------------