diff --git a/.gitignore b/.gitignore
index 01f3d17..35dcf2e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -63,4 +63,6 @@ target/
.DS_Store
.mypy_cache/
-.vscode/
\ No newline at end of file
+.vscode/
+
+*.sqlite3
\ No newline at end of file
diff --git a/README.md b/README.md
index b071e54..4996a93 100644
--- a/README.md
+++ b/README.md
@@ -104,3 +104,78 @@ schema = graphene.Schema(query=Query, subscription=Subscription)
```
You can see a full example here: https://github.com/graphql-python/graphql-ws/tree/master/examples/flask_gevent
+
+
+### Django Channels
+
+
+First `pip install channels` and it to your django apps
+
+Then add the following to your settings.py
+
+```python
+ CHANNELS_WS_PROTOCOLS = ["graphql-ws", ]
+ CHANNEL_LAYERS = {
+ "default": {
+ "BACKEND": "asgiref.inmemory.ChannelLayer",
+ "ROUTING": "django_subscriptions.urls.channel_routing",
+ },
+
+ }
+```
+
+Setup your graphql schema
+
+```python
+import graphene
+from rx import Observable
+
+
+class Query(graphene.ObjectType):
+ hello = graphene.String()
+
+ def resolve_hello(self, info, **kwargs):
+ return 'world'
+
+class Subscription(graphene.ObjectType):
+
+ count_seconds = graphene.Int(up_to=graphene.Int())
+
+
+ def resolve_count_seconds(
+ root,
+ info,
+ up_to=5
+ ):
+ return Observable.interval(1000)\
+ .map(lambda i: "{0}".format(i))\
+ .take_while(lambda i: int(i) <= up_to)
+
+
+
+schema = graphene.Schema(
+ query=Query,
+ subscription=Subscription
+)
+
+
+````
+
+Setup your schema in settings.py
+
+```python
+GRAPHENE = {
+ 'SCHEMA': 'path.to.schema'
+}
+```
+
+and finally add the channel routes
+
+```python
+from channels.routing import route_class
+from graphql_ws.django_channels import GraphQLSubscriptionConsumer
+
+channel_routing = [
+ route_class(GraphQLSubscriptionConsumer, path=r"^/subscriptions"),
+]
+```
\ No newline at end of file
diff --git a/examples/django_subscriptions/django_subscriptions/__init__.py b/examples/django_subscriptions/django_subscriptions/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/examples/django_subscriptions/django_subscriptions/asgi.py b/examples/django_subscriptions/django_subscriptions/asgi.py
new file mode 100644
index 0000000..e6edd7d
--- /dev/null
+++ b/examples/django_subscriptions/django_subscriptions/asgi.py
@@ -0,0 +1,6 @@
+import os
+from channels.asgi import get_channel_layer
+
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "django_subscriptions.settings")
+
+channel_layer = get_channel_layer()
\ No newline at end of file
diff --git a/examples/django_subscriptions/django_subscriptions/schema.py b/examples/django_subscriptions/django_subscriptions/schema.py
new file mode 100644
index 0000000..b55d76e
--- /dev/null
+++ b/examples/django_subscriptions/django_subscriptions/schema.py
@@ -0,0 +1,23 @@
+import graphene
+from rx import Observable
+
+
+class Query(graphene.ObjectType):
+ hello = graphene.String()
+
+ def resolve_hello(self, info, **kwargs):
+ return 'world'
+
+class Subscription(graphene.ObjectType):
+
+ count_seconds = graphene.Int(up_to=graphene.Int())
+
+
+ def resolve_count_seconds(root, info, up_to=5):
+ return Observable.interval(1000)\
+ .map(lambda i: "{0}".format(i))\
+ .take_while(lambda i: int(i) <= up_to)
+
+
+
+schema = graphene.Schema(query=Query, subscription=Subscription)
\ No newline at end of file
diff --git a/examples/django_subscriptions/django_subscriptions/settings.py b/examples/django_subscriptions/django_subscriptions/settings.py
new file mode 100644
index 0000000..45d0471
--- /dev/null
+++ b/examples/django_subscriptions/django_subscriptions/settings.py
@@ -0,0 +1,137 @@
+"""
+Django settings for django_subscriptions project.
+
+Generated by 'django-admin startproject' using Django 1.11.6.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/1.11/topics/settings/
+
+For the full list of settings and their values, see
+https://docs.djangoproject.com/en/1.11/ref/settings/
+"""
+
+import os
+
+# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
+BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+
+
+# Quick-start development settings - unsuitable for production
+# See https://docs.djangoproject.com/en/1.11/howto/deployment/checklist/
+
+# SECURITY WARNING: keep the secret key used in production secret!
+SECRET_KEY = 'fa#kz8m$l6)4(np9+-j_-z!voa090mah!s9^4jp=kj!^nwdq^c'
+
+# SECURITY WARNING: don't run with debug turned on in production!
+DEBUG = True
+
+ALLOWED_HOSTS = []
+
+
+# Application definition
+
+INSTALLED_APPS = [
+ 'django.contrib.admin',
+ 'django.contrib.auth',
+ 'django.contrib.contenttypes',
+ 'django.contrib.sessions',
+ 'django.contrib.messages',
+ 'django.contrib.staticfiles',
+ 'channels',
+]
+
+MIDDLEWARE = [
+ 'django.middleware.security.SecurityMiddleware',
+ 'django.contrib.sessions.middleware.SessionMiddleware',
+ 'django.middleware.common.CommonMiddleware',
+ 'django.middleware.csrf.CsrfViewMiddleware',
+ 'django.contrib.auth.middleware.AuthenticationMiddleware',
+ 'django.contrib.messages.middleware.MessageMiddleware',
+ 'django.middleware.clickjacking.XFrameOptionsMiddleware',
+]
+
+ROOT_URLCONF = 'django_subscriptions.urls'
+
+TEMPLATES = [
+ {
+ 'BACKEND': 'django.template.backends.django.DjangoTemplates',
+ 'DIRS': [],
+ 'APP_DIRS': True,
+ 'OPTIONS': {
+ 'context_processors': [
+ 'django.template.context_processors.debug',
+ 'django.template.context_processors.request',
+ 'django.contrib.auth.context_processors.auth',
+ 'django.contrib.messages.context_processors.messages',
+ ],
+ },
+ },
+]
+
+WSGI_APPLICATION = 'django_subscriptions.wsgi.application'
+
+
+# Database
+# https://docs.djangoproject.com/en/1.11/ref/settings/#databases
+
+DATABASES = {
+ 'default': {
+ 'ENGINE': 'django.db.backends.sqlite3',
+ 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
+ }
+}
+
+
+# Password validation
+# https://docs.djangoproject.com/en/1.11/ref/settings/#auth-password-validators
+
+AUTH_PASSWORD_VALIDATORS = [
+ {
+ 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
+ },
+ {
+ 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
+ },
+ {
+ 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
+ },
+ {
+ 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
+ },
+]
+
+
+# Internationalization
+# https://docs.djangoproject.com/en/1.11/topics/i18n/
+
+LANGUAGE_CODE = 'en-us'
+
+TIME_ZONE = 'UTC'
+
+USE_I18N = True
+
+USE_L10N = True
+
+USE_TZ = True
+
+
+# Static files (CSS, JavaScript, Images)
+# https://docs.djangoproject.com/en/1.11/howto/static-files/
+
+STATIC_URL = '/static/'
+CHANNELS_WS_PROTOCOLS = ["graphql-ws", ]
+CHANNEL_LAYERS = {
+ "default": {
+ "BACKEND": "asgi_redis.RedisChannelLayer",
+ "CONFIG": {
+ "hosts": [("localhost", 6379)],
+ },
+ "ROUTING": "django_subscriptions.urls.channel_routing",
+ },
+
+}
+
+
+GRAPHENE = {
+ 'SCHEMA': 'django_subscriptions.schema.schema'
+}
\ No newline at end of file
diff --git a/examples/django_subscriptions/django_subscriptions/template.py b/examples/django_subscriptions/django_subscriptions/template.py
new file mode 100644
index 0000000..b067ae5
--- /dev/null
+++ b/examples/django_subscriptions/django_subscriptions/template.py
@@ -0,0 +1,125 @@
+
+from string import Template
+
+
+def render_graphiql():
+ return Template('''
+
+
+
+
+ GraphiQL
+
+
+
+
+
+
+
+
+
+
+
+
+
+''').substitute(
+ GRAPHIQL_VERSION='0.11.10',
+ SUBSCRIPTIONS_TRANSPORT_VERSION='0.7.0',
+ subscriptionsEndpoint='ws://localhost:8000/subscriptions',
+ # subscriptionsEndpoint='ws://localhost:5000/',
+ endpointURL='/graphql',
+ )
diff --git a/examples/django_subscriptions/django_subscriptions/urls.py b/examples/django_subscriptions/django_subscriptions/urls.py
new file mode 100644
index 0000000..3848d22
--- /dev/null
+++ b/examples/django_subscriptions/django_subscriptions/urls.py
@@ -0,0 +1,40 @@
+"""django_subscriptions URL Configuration
+
+The `urlpatterns` list routes URLs to views. For more information please see:
+ https://docs.djangoproject.com/en/1.11/topics/http/urls/
+Examples:
+Function views
+ 1. Add an import: from my_app import views
+ 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home')
+Class-based views
+ 1. Add an import: from other_app.views import Home
+ 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home')
+Including another URLconf
+ 1. Import the include() function: from django.conf.urls import url, include
+ 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls'))
+"""
+from django.conf.urls import url
+from django.contrib import admin
+from .template import render_graphiql
+from django.http import HttpResponse
+
+from graphene_django.views import GraphQLView
+from django.views.decorators.csrf import csrf_exempt
+
+
+def graphiql(request):
+ response = HttpResponse(content=render_graphiql())
+ return response
+
+urlpatterns = [
+ url(r'^admin/', admin.site.urls),
+ url(r'^graphiql/', graphiql),
+ url(r'^graphql', csrf_exempt(GraphQLView.as_view(graphiql=True)))
+]
+
+from channels.routing import route_class
+from graphql_ws.django_channels import GraphQLSubscriptionConsumer
+
+channel_routing = [
+ route_class(GraphQLSubscriptionConsumer, path=r"^/subscriptions"),
+]
\ No newline at end of file
diff --git a/examples/django_subscriptions/django_subscriptions/wsgi.py b/examples/django_subscriptions/django_subscriptions/wsgi.py
new file mode 100644
index 0000000..fe762ba
--- /dev/null
+++ b/examples/django_subscriptions/django_subscriptions/wsgi.py
@@ -0,0 +1,16 @@
+"""
+WSGI config for django_subscriptions project.
+
+It exposes the WSGI callable as a module-level variable named ``application``.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/1.11/howto/deployment/wsgi/
+"""
+
+import os
+
+from django.core.wsgi import get_wsgi_application
+
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "django_subscriptions.settings")
+
+application = get_wsgi_application()
diff --git a/examples/django_subscriptions/manage.py b/examples/django_subscriptions/manage.py
new file mode 100755
index 0000000..347bd13
--- /dev/null
+++ b/examples/django_subscriptions/manage.py
@@ -0,0 +1,22 @@
+#!/usr/bin/env python
+import os
+import sys
+
+if __name__ == "__main__":
+ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "django_subscriptions.settings")
+ try:
+ from django.core.management import execute_from_command_line
+ except ImportError:
+ # The above import may fail for some other reason. Ensure that the
+ # issue is really that Django is missing to avoid masking other
+ # exceptions on Python 2.
+ try:
+ import django
+ except ImportError:
+ raise ImportError(
+ "Couldn't import Django. Are you sure it's installed and "
+ "available on your PYTHONPATH environment variable? Did you "
+ "forget to activate a virtual environment?"
+ )
+ raise
+ execute_from_command_line(sys.argv)
diff --git a/graphql_ws/base.py b/graphql_ws/base.py
index b6c7aa1..9556192 100644
--- a/graphql_ws/base.py
+++ b/graphql_ws/base.py
@@ -179,9 +179,12 @@ def handle(self, ws, request_context=None):
def on_message(self, connection_context, message):
try:
- parsed_message = json.loads(message)
- assert isinstance(
- parsed_message, dict), "Payload must be an object."
+ if not isinstance(message, dict):
+ parsed_message = json.loads(message)
+ assert isinstance(
+ parsed_message, dict), "Payload must be an object."
+ else:
+ parsed_message = message
except Exception as e:
return self.send_error(connection_context, None, e)
diff --git a/graphql_ws/django_channels.py b/graphql_ws/django_channels.py
new file mode 100644
index 0000000..5b75629
--- /dev/null
+++ b/graphql_ws/django_channels.py
@@ -0,0 +1,132 @@
+from channels.generic.websockets import JsonWebsocketConsumer
+from .base import BaseConnectionContext
+import json
+from graphql.execution.executors.sync import SyncExecutor
+from .base import (
+ ConnectionClosedException,
+ BaseConnectionContext,
+ BaseSubscriptionServer
+)
+from .constants import (
+ GQL_CONNECTION_ACK,
+ GQL_CONNECTION_ERROR
+)
+from django.conf import settings
+from rx import Observer, Observable
+from django.conf import settings
+from graphene_django.settings import graphene_settings
+
+class DjangoChannelConnectionContext(BaseConnectionContext):
+
+ def __init__(self, message, request_context = None):
+ self.message = message
+ self.operations = {}
+ self.request_context = request_context
+
+ def send(self, data):
+ self.message.reply_channel.send(data)
+
+ def close(self, reason):
+ data = {
+ 'close': True,
+ 'text': reason
+ }
+ self.message.reply_channel.send(data)
+
+class DjangoChannelSubscriptionServer(BaseSubscriptionServer):
+
+ def get_graphql_params(self, *args, **kwargs):
+ params = super(DjangoChannelSubscriptionServer,
+ self).get_graphql_params(*args, **kwargs)
+ return dict(params, executor=SyncExecutor())
+
+ def handle(self, message, connection_context):
+ self.on_message(connection_context, message)
+
+ def send_message(self, connection_context, op_id=None, op_type=None, payload=None):
+ message = {}
+ if op_id is not None:
+ message['id'] = op_id
+ if op_type is not None:
+ message['type'] = op_type
+ if payload is not None:
+ message['payload'] = payload
+
+ assert message, "You need to send at least one thing"
+ return connection_context.send({'text': json.dumps(message)})
+
+ def on_open(self, connection_context):
+ pass
+
+ def on_connect(self, connection_context, payload):
+ pass
+
+ def on_connection_init(self, connection_context, op_id, payload):
+ try:
+ self.on_connect(connection_context, payload)
+ self.send_message(connection_context, op_type=GQL_CONNECTION_ACK)
+
+ except Exception as e:
+ self.send_error(connection_context, op_id, e, GQL_CONNECTION_ERROR)
+ connection_context.close(1011)
+
+ def on_start(self, connection_context, op_id, params):
+ try:
+ execution_result = self.execute(
+ connection_context.request_context, params)
+ assert isinstance(
+ execution_result, Observable), "A subscription must return an observable"
+ execution_result.subscribe(SubscriptionObserver(
+ connection_context,
+ op_id,
+ self.send_execution_result,
+ self.send_error,
+ self.on_close
+ ))
+ except Exception as e:
+ self.send_error(connection_context, op_id, str(e))
+
+ def on_close(self, connection_context):
+ remove_operations = list(connection_context.operations.keys())
+ for op_id in remove_operations:
+ self.unsubscribe(connection_context, op_id)
+
+ def on_stop(self, connection_context, op_id):
+ self.unsubscribe(connection_context, op_id)
+
+
+class GraphQLSubscriptionConsumer(JsonWebsocketConsumer):
+ http_user_and_session = True
+ strict_ordering = True
+
+ def connect(self, message, **kwargs):
+ message.reply_channel.send({"accept": True})
+
+
+ def receive(self, content, **kwargs):
+ """
+ Called when a message is received with either text or bytes
+ filled out.
+ """
+ self.connection_context = DjangoChannelConnectionContext(self.message)
+ self.subscription_server = DjangoChannelSubscriptionServer(graphene_settings.SCHEMA)
+ self.subscription_server.on_open(self.connection_context)
+ self.subscription_server.handle(content, self.connection_context)
+
+class SubscriptionObserver(Observer):
+
+ def __init__(self, connection_context, op_id, send_execution_result, send_error, on_close):
+ self.connection_context = connection_context
+ self.op_id = op_id
+ self.send_execution_result = send_execution_result
+ self.send_error = send_error
+ self.on_close = on_close
+
+ def on_next(self, value):
+ self.send_execution_result(self.connection_context, self.op_id, value)
+
+ def on_completed(self):
+ self.on_close(self.connection_context)
+
+ def on_error(self, error):
+ self.send_error(self.connection_context, self.op_id, error)
diff --git a/requirements_dev.txt b/requirements_dev.txt
index ca85950..075fd14 100644
--- a/requirements_dev.txt
+++ b/requirements_dev.txt
@@ -10,3 +10,5 @@ pytest==2.9.2
pytest-runner==2.11.1
gevent
graphene>=2.0
+django
+channels