├── tests
├── __init__.py
├── migrations
│ ├── __init__.py
│ └── 0001_initial.py
├── models.py
├── test_settings.py
└── test_bindings.py
├── setup.cfg
├── channels_framework
├── __init__.py
├── urls.py
├── settings.py
├── templates
│ └── debugger
│ │ └── index.html
├── mixins.py
└── bindings.py
├── requirements.txt
├── MANIFEST.in
├── .travis.yml
├── tox.ini
├── runtests.py
├── .gitignore
├── setup.py
├── LICENSE
└── README.rst
/tests/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/migrations/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/setup.cfg:
--------------------------------------------------------------------------------
1 | [metadata]
2 | description-file = README.md
--------------------------------------------------------------------------------
/channels_framework/__init__.py:
--------------------------------------------------------------------------------
1 | __version__ = "0.2.2"
2 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | django
2 | djangorestframework
3 | channels
4 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | include LICENSE
2 | include README.rst
3 | recursive-include channels_api/templates *
4 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------