diff --git a/README.md b/README.md index 8605065..3feb233 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,42 @@ urlpatterns = [ ] ``` +### Subscription Support + +The `graphene-django` project does not currently support GraphQL subscriptions out of the box. However, there are +several community-driven modules for adding subscription support, and the GraphiQL interface provided by +`graphene-django` supports subscriptions over websockets. + +To implement websocket-based support for GraphQL subscriptions, you'll need to: + +1. Install and configure [`django-channels`](https://channels.readthedocs.io/en/latest/installation.html). +2. Install and configure1, 2 a third-party module for adding subscription support over websockets. A few + options include: + - [`graphql-python/graphql-ws`](https://github.com/graphql-python/graphql-ws) + - [`datavance/django-channels-graphql-ws`](https://github.com/datadvance/DjangoChannelsGraphqlWs) + - [`jaydenwindle/graphene-subscriptions`](https://github.com/jaydenwindle/graphene-subscriptions) +3. Ensure that your application (or at least your GraphQL endpoint) is being served via an ASGI protocol server like + `daphne` (built in to `django-channels`), [`uvicorn`](https://www.uvicorn.org/), or + [`hypercorn`](https://pgjones.gitlab.io/hypercorn/). + +> **1 Note:** By default, the GraphiQL interface that comes with `graphene-django` assumes that you are +> handling subscriptions at the same path as any other operation (i.e., you configured both `urls.py` and `routing.py` +> to handle GraphQL operations at the same path, like `/graphql`). +> +> If these URLs differ, GraphiQL will try to run your subscription over HTTP, which will produce an error. If you need +> to use a different URL for handling websocket connections, you can configure `SUBSCRIPTION_PATH` in your +> `settings.py`: +> +> ```python +> GRAPHENE = { +> # ... +> "SUBSCRIPTION_PATH": "/ws/graphql" # The path you configured in `routing.py`, including a leading slash. +> } +> ``` + +Once your application is properly configured to handle subscriptions, you can use the GraphiQL interface to test +subscriptions like any other operation. + ## Examples Here is a simple Django model: diff --git a/graphene_django/settings.py b/graphene_django/settings.py index 666ad8a..52cca89 100644 --- a/graphene_django/settings.py +++ b/graphene_django/settings.py @@ -39,6 +39,8 @@ DEFAULTS = { # Set to True to enable v3 naming convention for choice field Enum's "DJANGO_CHOICE_FIELD_ENUM_V3_NAMING": False, "DJANGO_CHOICE_FIELD_ENUM_CUSTOM_NAME": None, + # Use a separate path for handling subscriptions. + "SUBSCRIPTION_PATH": None, } if settings.DEBUG: diff --git a/graphene_django/static/graphene_django/graphiql.js b/graphene_django/static/graphene_django/graphiql.js index c939216..1bc3255 100644 --- a/graphene_django/static/graphene_django/graphiql.js +++ b/graphene_django/static/graphene_django/graphiql.js @@ -1,5 +1,13 @@ -(function() { - +(function ( + document, + GRAPHENE_SETTINGS, + GraphiQL, + React, + ReactDOM, + SubscriptionsTransportWs, + history, + location, +) { // Parse the cookie value for a CSRF token var csrftoken; var cookies = ('; ' + document.cookie).split('; csrftoken='); @@ -11,7 +19,7 @@ // Collect the URL parameters var parameters = {}; - window.location.hash.substr(1).split('&').forEach(function (entry) { + location.hash.substr(1).split('&').forEach(function (entry) { var eq = entry.indexOf('='); if (eq >= 0) { parameters[decodeURIComponent(entry.slice(0, eq))] = @@ -41,7 +49,7 @@ var fetchURL = locationQuery(otherParams); // Defines a GraphQL fetcher using the fetch API. - function graphQLFetcher(graphQLParams) { + function httpClient(graphQLParams) { var headers = { 'Accept': 'application/json', 'Content-Type': 'application/json' @@ -64,6 +72,68 @@ } }); } + + // Derive the subscription URL. If the SUBSCRIPTION_URL setting is specified, uses that value. Otherwise + // assumes the current window location with an appropriate websocket protocol. + var subscribeURL = + location.origin.replace(/^http/, "ws") + + (GRAPHENE_SETTINGS.subscriptionPath || location.pathname); + + // Create a subscription client. + var subscriptionClient = new SubscriptionsTransportWs.SubscriptionClient( + subscribeURL, + { + // Reconnect after any interruptions. + reconnect: true, + // Delay socket initialization until the first subscription is started. + lazy: true, + }, + ); + + // Keep a reference to the currently-active subscription, if available. + var activeSubscription = null; + + // Define a GraphQL fetcher that can intelligently route queries based on the operation type. + function graphQLFetcher(graphQLParams) { + var operationType = getOperationType(graphQLParams); + + // If we're about to execute a new operation, and we have an active subscription, + // unsubscribe before continuing. + if (activeSubscription) { + activeSubscription.unsubscribe(); + activeSubscription = null; + } + + if (operationType === "subscription") { + return { + subscribe: function (observer) { + subscriptionClient.request(graphQLParams).subscribe(observer); + activeSubscription = subscriptionClient; + }, + }; + } else { + return httpClient(graphQLParams); + } + } + + // Determine the type of operation being executed for a given set of GraphQL parameters. + function getOperationType(graphQLParams) { + // Run a regex against the query to determine the operation type (query, mutation, subscription). + var operationRegex = new RegExp( + // Look for lines that start with an operation keyword, ignoring whitespace. + "^\\s*(query|mutation|subscription)\\s+" + + // The operation keyword should be followed by the operationName in the GraphQL parameters. + graphQLParams.operationName + + // The line should eventually encounter an opening curly brace. + "[^\\{]*\\{", + // Enable multiline matching. + "m", + ); + var match = operationRegex.exec(graphQLParams.query); + + return match[1]; + } + // When the query and variables string is edited, update the URL bar so // that it can be easily shared. function onEditQuery(newQuery) { @@ -83,10 +153,10 @@ } var options = { fetcher: graphQLFetcher, - onEditQuery: onEditQuery, - onEditVariables: onEditVariables, - onEditOperationName: onEditOperationName, - query: parameters.query, + onEditQuery: onEditQuery, + onEditVariables: onEditVariables, + onEditOperationName: onEditOperationName, + query: parameters.query, } if (parameters.variables) { options.variables = parameters.variables; @@ -99,4 +169,13 @@ React.createElement(GraphiQL, options), document.getElementById("editor") ); -})(); +})( + document, + window.GRAPHENE_SETTINGS, + window.GraphiQL, + window.React, + window.ReactDOM, + window.SubscriptionsTransportWs, + window.history, + window.location, +); diff --git a/graphene_django/templates/graphene/graphiql.html b/graphene_django/templates/graphene/graphiql.html index d0546bd..20ac1d0 100644 --- a/graphene_django/templates/graphene/graphiql.html +++ b/graphene_django/templates/graphene/graphiql.html @@ -29,10 +29,19 @@ add "&raw" to the end of the URL within a browser. crossorigin="anonymous"> +
{% csrf_token %} +