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