Skip to content

Commit 6aa6aaa

Browse files
authored
Update GraphiQL, add GraphiQL subscription support (#1001)
1 parent 1205e29 commit 6aa6aaa

File tree

7 files changed

+169
-13
lines changed

7 files changed

+169
-13
lines changed

docs/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ For more advanced use, check out the Relay tutorial.
2828
fields
2929
extra-types
3030
mutations
31+
subscriptions
3132
filtering
3233
authorization
3334
debug

docs/settings.rst

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ Default: ``100``
104104
105105
106106
``CAMELCASE_ERRORS``
107-
------------------------------------
107+
--------------------
108108

109109
When set to ``True`` field names in the ``errors`` object will be camel case.
110110
By default they will be snake case.
@@ -151,7 +151,7 @@ Default: ``False``
151151

152152

153153
``DJANGO_CHOICE_FIELD_ENUM_CUSTOM_NAME``
154-
--------------------------------------
154+
----------------------------------------
155155

156156
Define the path of a function that takes the Django choice field and returns a string to completely customise the naming for the Enum type.
157157

@@ -170,3 +170,19 @@ Default: ``None``
170170
GRAPHENE = {
171171
'DJANGO_CHOICE_FIELD_ENUM_CUSTOM_NAME': "myapp.utils.enum_naming"
172172
}
173+
174+
175+
``SUBSCRIPTION_PATH``
176+
---------------------
177+
178+
Define an alternative URL path where subscription operations should be routed.
179+
180+
The GraphiQL interface will use this setting to intelligently route subscription operations. This is useful if you have more advanced infrastructure requirements that prevent websockets from being handled at the same path (e.g., a WSGI server listening at ``/graphql`` and an ASGI server listening at ``/ws/graphql``).
181+
182+
Default: ``None``
183+
184+
.. code:: python
185+
186+
GRAPHENE = {
187+
'SUBSCRIPTION_PATH': "/ws/graphql"
188+
}

docs/subscriptions.rst

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
Subscriptions
2+
=============
3+
4+
The ``graphene-django`` project does not currently support GraphQL subscriptions out of the box. However, there are
5+
several community-driven modules for adding subscription support, and the provided GraphiQL interface supports
6+
running subscription operations over a websocket.
7+
8+
To implement websocket-based support for GraphQL subscriptions, you’ll need to do the following:
9+
10+
1. Install and configure `django-channels <https://channels.readthedocs.io/en/latest/installation.html>`_.
11+
2. Install and configure* a third-party module for adding subscription support over websockets. A few options include:
12+
13+
- `graphql-python/graphql-ws <https://github.com/graphql-python/graphql-ws>`_
14+
- `datavance/django-channels-graphql-ws <https://github.com/datadvance/DjangoChannelsGraphqlWs>`_
15+
- `jaydenwindle/graphene-subscriptions <https://github.com/jaydenwindle/graphene-subscriptions>`_
16+
17+
3. Ensure that your application (or at least your GraphQL endpoint) is being served via an ASGI protocol server like
18+
daphne (built in to ``django-channels``), `uvicorn <https://www.uvicorn.org/>`_, or
19+
`hypercorn <https://pgjones.gitlab.io/hypercorn/>`_.
20+
21+
..
22+
23+
*** Note:** By default, the GraphiQL interface that comes with
24+
``graphene-django`` assumes that you are handling subscriptions at
25+
the same path as any other operation (i.e., you configured both
26+
``urls.py`` and ``routing.py`` to handle GraphQL operations at the
27+
same path, like ``/graphql``).
28+
29+
If these URLs differ, GraphiQL will try to run your subscription over
30+
HTTP, which will produce an error. If you need to use a different URL
31+
for handling websocket connections, you can configure
32+
``SUBSCRIPTION_PATH`` in your ``settings.py``:
33+
34+
.. code:: python
35+
36+
GRAPHENE = {
37+
# ...
38+
"SUBSCRIPTION_PATH": "/ws/graphql" # The path you configured in `routing.py`, including a leading slash.
39+
}
40+
41+
Once your application is properly configured to handle subscriptions, you can use the GraphiQL interface to test
42+
subscriptions like any other operation.

graphene_django/settings.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@
3939
# Set to True to enable v3 naming convention for choice field Enum's
4040
"DJANGO_CHOICE_FIELD_ENUM_V3_NAMING": False,
4141
"DJANGO_CHOICE_FIELD_ENUM_CUSTOM_NAME": None,
42+
# Use a separate path for handling subscriptions.
43+
"SUBSCRIPTION_PATH": None,
4244
}
4345

4446
if settings.DEBUG:

graphene_django/static/graphene_django/graphiql.js

Lines changed: 88 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
1-
(function() {
2-
1+
(function (
2+
document,
3+
GRAPHENE_SETTINGS,
4+
GraphiQL,
5+
React,
6+
ReactDOM,
7+
SubscriptionsTransportWs,
8+
history,
9+
location,
10+
) {
311
// Parse the cookie value for a CSRF token
412
var csrftoken;
513
var cookies = ('; ' + document.cookie).split('; csrftoken=');
@@ -11,7 +19,7 @@
1119

1220
// Collect the URL parameters
1321
var parameters = {};
14-
window.location.hash.substr(1).split('&').forEach(function (entry) {
22+
location.hash.substr(1).split('&').forEach(function (entry) {
1523
var eq = entry.indexOf('=');
1624
if (eq >= 0) {
1725
parameters[decodeURIComponent(entry.slice(0, eq))] =
@@ -41,7 +49,7 @@
4149
var fetchURL = locationQuery(otherParams);
4250

4351
// Defines a GraphQL fetcher using the fetch API.
44-
function graphQLFetcher(graphQLParams) {
52+
function httpClient(graphQLParams) {
4553
var headers = {
4654
'Accept': 'application/json',
4755
'Content-Type': 'application/json'
@@ -64,6 +72,68 @@
6472
}
6573
});
6674
}
75+
76+
// Derive the subscription URL. If the SUBSCRIPTION_URL setting is specified, uses that value. Otherwise
77+
// assumes the current window location with an appropriate websocket protocol.
78+
var subscribeURL =
79+
location.origin.replace(/^http/, "ws") +
80+
(GRAPHENE_SETTINGS.subscriptionPath || location.pathname);
81+
82+
// Create a subscription client.
83+
var subscriptionClient = new SubscriptionsTransportWs.SubscriptionClient(
84+
subscribeURL,
85+
{
86+
// Reconnect after any interruptions.
87+
reconnect: true,
88+
// Delay socket initialization until the first subscription is started.
89+
lazy: true,
90+
},
91+
);
92+
93+
// Keep a reference to the currently-active subscription, if available.
94+
var activeSubscription = null;
95+
96+
// Define a GraphQL fetcher that can intelligently route queries based on the operation type.
97+
function graphQLFetcher(graphQLParams) {
98+
var operationType = getOperationType(graphQLParams);
99+
100+
// If we're about to execute a new operation, and we have an active subscription,
101+
// unsubscribe before continuing.
102+
if (activeSubscription) {
103+
activeSubscription.unsubscribe();
104+
activeSubscription = null;
105+
}
106+
107+
if (operationType === "subscription") {
108+
return {
109+
subscribe: function (observer) {
110+
subscriptionClient.request(graphQLParams).subscribe(observer);
111+
activeSubscription = subscriptionClient;
112+
},
113+
};
114+
} else {
115+
return httpClient(graphQLParams);
116+
}
117+
}
118+
119+
// Determine the type of operation being executed for a given set of GraphQL parameters.
120+
function getOperationType(graphQLParams) {
121+
// Run a regex against the query to determine the operation type (query, mutation, subscription).
122+
var operationRegex = new RegExp(
123+
// Look for lines that start with an operation keyword, ignoring whitespace.
124+
"^\\s*(query|mutation|subscription)\\s+" +
125+
// The operation keyword should be followed by the operationName in the GraphQL parameters.
126+
graphQLParams.operationName +
127+
// The line should eventually encounter an opening curly brace.
128+
"[^\\{]*\\{",
129+
// Enable multiline matching.
130+
"m",
131+
);
132+
var match = operationRegex.exec(graphQLParams.query);
133+
134+
return match[1];
135+
}
136+
67137
// When the query and variables string is edited, update the URL bar so
68138
// that it can be easily shared.
69139
function onEditQuery(newQuery) {
@@ -83,10 +153,10 @@
83153
}
84154
var options = {
85155
fetcher: graphQLFetcher,
86-
onEditQuery: onEditQuery,
87-
onEditVariables: onEditVariables,
88-
onEditOperationName: onEditOperationName,
89-
query: parameters.query,
156+
onEditQuery: onEditQuery,
157+
onEditVariables: onEditVariables,
158+
onEditOperationName: onEditOperationName,
159+
query: parameters.query,
90160
}
91161
if (parameters.variables) {
92162
options.variables = parameters.variables;
@@ -99,4 +169,13 @@
99169
React.createElement(GraphiQL, options),
100170
document.getElementById("editor")
101171
);
102-
})();
172+
})(
173+
document,
174+
window.GRAPHENE_SETTINGS,
175+
window.GraphiQL,
176+
window.React,
177+
window.ReactDOM,
178+
window.SubscriptionsTransportWs,
179+
window.history,
180+
window.location,
181+
);

graphene_django/templates/graphene/graphiql.html

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,19 @@
2929
crossorigin="anonymous"></script>
3030
<script src="https://cdn.jsdelivr.net/npm/graphiql@{{graphiql_version}}/graphiql.min.js"
3131
crossorigin="anonymous"></script>
32+
<script src="https://cdn.jsdelivr.net/npm/subscriptions-transport-ws@{{subscriptions_transport_ws_version}}/browser/client.js"
33+
crossorigin="anonymous"></script>
3234
</head>
3335
<body>
3436
<div id="editor"></div>
3537
{% csrf_token %}
38+
<script type="application/javascript">
39+
window.GRAPHENE_SETTINGS = {
40+
{% if subscription_path %}
41+
subscriptionPath: "{{subscription_path}}",
42+
{% endif %}
43+
};
44+
</script>
3645
<script src="{% static 'graphene_django/graphiql.js' %}"></script>
3746
</body>
3847
</html>

graphene_django/views.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,10 @@ def instantiate_middleware(middlewares):
5252

5353

5454
class GraphQLView(View):
55-
graphiql_version = "0.14.0"
55+
graphiql_version = "1.0.3"
5656
graphiql_template = "graphene/graphiql.html"
57-
react_version = "16.8.6"
57+
react_version = "16.13.1"
58+
subscriptions_transport_ws_version = "0.9.16"
5859

5960
schema = None
6061
graphiql = False
@@ -64,6 +65,7 @@ class GraphQLView(View):
6465
root_value = None
6566
pretty = False
6667
batch = False
68+
subscription_path = None
6769

6870
def __init__(
6971
self,
@@ -75,6 +77,7 @@ def __init__(
7577
pretty=False,
7678
batch=False,
7779
backend=None,
80+
subscription_path=None,
7881
):
7982
if not schema:
8083
schema = graphene_settings.SCHEMA
@@ -97,6 +100,8 @@ def __init__(
97100
self.graphiql = self.graphiql or graphiql
98101
self.batch = self.batch or batch
99102
self.backend = backend
103+
if subscription_path is None:
104+
subscription_path = graphene_settings.SUBSCRIPTION_PATH
100105

101106
assert isinstance(
102107
self.schema, GraphQLSchema
@@ -134,6 +139,8 @@ def dispatch(self, request, *args, **kwargs):
134139
request,
135140
graphiql_version=self.graphiql_version,
136141
react_version=self.react_version,
142+
subscriptions_transport_ws_version=self.subscriptions_transport_ws_version,
143+
subscription_path=self.subscription_path,
137144
)
138145

139146
if self.batch:

0 commit comments

Comments
 (0)