Update GraphiQL, add GraphiQL subscription support

* Update the GraphiQL template to use the latest versions of react,
  react-dom, graphiql, and (new) subscriptions-transport-ws.
* Add support for websocket connections and subscriptions to the
  GraphiQL template.
* Add a `SUBSCRIPTION_URL` configuration option to allow GraphiQL to
  route subscriptions to a different path (allowing for more advanced
  infrastructure scenarios).
* Update the README to include some starting points for implementing
  subscriptions and configuring `SUBSCRIPTION_URL`.
This commit is contained in:
Eric Abruzzese 2020-07-11 19:11:31 -04:00
parent 1205e29bef
commit ab569ea2d6
5 changed files with 144 additions and 11 deletions

View File

@ -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 configure<sup>1, 2</sup> 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/).
> **<sup>1</sup> 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 ## Examples
Here is a simple Django model: Here is a simple Django model:

View File

@ -39,6 +39,8 @@ DEFAULTS = {
# Set to True to enable v3 naming convention for choice field Enum's # Set to True to enable v3 naming convention for choice field Enum's
"DJANGO_CHOICE_FIELD_ENUM_V3_NAMING": False, "DJANGO_CHOICE_FIELD_ENUM_V3_NAMING": False,
"DJANGO_CHOICE_FIELD_ENUM_CUSTOM_NAME": None, "DJANGO_CHOICE_FIELD_ENUM_CUSTOM_NAME": None,
# Use a separate path for handling subscriptions.
"SUBSCRIPTION_PATH": None,
} }
if settings.DEBUG: if settings.DEBUG:

View File

@ -1,5 +1,13 @@
(function() { (function (
document,
GRAPHENE_SETTINGS,
GraphiQL,
React,
ReactDOM,
SubscriptionsTransportWs,
history,
location,
) {
// Parse the cookie value for a CSRF token // Parse the cookie value for a CSRF token
var csrftoken; var csrftoken;
var cookies = ('; ' + document.cookie).split('; csrftoken='); var cookies = ('; ' + document.cookie).split('; csrftoken=');
@ -11,7 +19,7 @@
// Collect the URL parameters // Collect the URL parameters
var parameters = {}; var parameters = {};
window.location.hash.substr(1).split('&').forEach(function (entry) { location.hash.substr(1).split('&').forEach(function (entry) {
var eq = entry.indexOf('='); var eq = entry.indexOf('=');
if (eq >= 0) { if (eq >= 0) {
parameters[decodeURIComponent(entry.slice(0, eq))] = parameters[decodeURIComponent(entry.slice(0, eq))] =
@ -41,7 +49,7 @@
var fetchURL = locationQuery(otherParams); var fetchURL = locationQuery(otherParams);
// Defines a GraphQL fetcher using the fetch API. // Defines a GraphQL fetcher using the fetch API.
function graphQLFetcher(graphQLParams) { function httpClient(graphQLParams) {
var headers = { var headers = {
'Accept': 'application/json', 'Accept': 'application/json',
'Content-Type': '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 // When the query and variables string is edited, update the URL bar so
// that it can be easily shared. // that it can be easily shared.
function onEditQuery(newQuery) { function onEditQuery(newQuery) {
@ -99,4 +169,13 @@
React.createElement(GraphiQL, options), React.createElement(GraphiQL, options),
document.getElementById("editor") document.getElementById("editor")
); );
})(); })(
document,
window.GRAPHENE_SETTINGS,
window.GraphiQL,
window.React,
window.ReactDOM,
window.SubscriptionsTransportWs,
window.history,
window.location,
);

View File

@ -29,10 +29,19 @@ add "&raw" to the end of the URL within a browser.
crossorigin="anonymous"></script> crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/graphiql@{{graphiql_version}}/graphiql.min.js" <script src="https://cdn.jsdelivr.net/npm/graphiql@{{graphiql_version}}/graphiql.min.js"
crossorigin="anonymous"></script> crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/subscriptions-transport-ws@{{subscriptions_transport_ws_version}}/browser/client.js"
crossorigin="anonymous"></script>
</head> </head>
<body> <body>
<div id="editor"></div> <div id="editor"></div>
{% csrf_token %} {% csrf_token %}
<script type="application/javascript">
window.GRAPHENE_SETTINGS = {
{% if subscription_path %}
subscriptionPath: "{{subscription_path}}",
{% endif %}
};
</script>
<script src="{% static 'graphene_django/graphiql.js' %}"></script> <script src="{% static 'graphene_django/graphiql.js' %}"></script>
</body> </body>
</html> </html>

View File

@ -52,9 +52,10 @@ def instantiate_middleware(middlewares):
class GraphQLView(View): class GraphQLView(View):
graphiql_version = "0.14.0" graphiql_version = "1.0.3"
graphiql_template = "graphene/graphiql.html" graphiql_template = "graphene/graphiql.html"
react_version = "16.8.6" react_version = "16.13.1"
subscriptions_transport_ws_version = "0.9.16"
schema = None schema = None
graphiql = False graphiql = False
@ -64,6 +65,7 @@ class GraphQLView(View):
root_value = None root_value = None
pretty = False pretty = False
batch = False batch = False
subscription_path = None
def __init__( def __init__(
self, self,
@ -75,6 +77,7 @@ class GraphQLView(View):
pretty=False, pretty=False,
batch=False, batch=False,
backend=None, backend=None,
subscription_path=None,
): ):
if not schema: if not schema:
schema = graphene_settings.SCHEMA schema = graphene_settings.SCHEMA
@ -97,6 +100,8 @@ class GraphQLView(View):
self.graphiql = self.graphiql or graphiql self.graphiql = self.graphiql or graphiql
self.batch = self.batch or batch self.batch = self.batch or batch
self.backend = backend self.backend = backend
if subscription_path is None:
subscription_path = graphene_settings.SUBSCRIPTION_PATH
assert isinstance( assert isinstance(
self.schema, GraphQLSchema self.schema, GraphQLSchema
@ -134,6 +139,8 @@ class GraphQLView(View):
request, request,
graphiql_version=self.graphiql_version, graphiql_version=self.graphiql_version,
react_version=self.react_version, react_version=self.react_version,
subscriptions_transport_ws_version=self.subscriptions_transport_ws_version,
subscription_path=self.subscription_path,
) )
if self.batch: if self.batch: