Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update GraphiQL, add GraphiQL subscription support #1001

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ For more advanced use, check out the Relay tutorial.
fields
extra-types
mutations
subscriptions
filtering
authorization
debug
Expand Down
20 changes: 18 additions & 2 deletions docs/settings.rst
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ Default: ``100``


``CAMELCASE_ERRORS``
------------------------------------
--------------------

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


``DJANGO_CHOICE_FIELD_ENUM_CUSTOM_NAME``
--------------------------------------
----------------------------------------

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.

Expand All @@ -170,3 +170,19 @@ Default: ``None``
GRAPHENE = {
'DJANGO_CHOICE_FIELD_ENUM_CUSTOM_NAME': "myapp.utils.enum_naming"
}


``SUBSCRIPTION_PATH``
---------------------

Define an alternative URL path where subscription operations should be routed.

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``).

Default: ``None``

.. code:: python

GRAPHENE = {
'SUBSCRIPTION_PATH': "/ws/graphql"
}
42 changes: 42 additions & 0 deletions docs/subscriptions.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
Subscriptions
=============

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 provided GraphiQL interface supports
running subscription operations over a websocket.

To implement websocket-based support for GraphQL subscriptions, you’ll need to do the following:

1. Install and configure `django-channels <https://channels.readthedocs.io/en/latest/installation.html>`_.
2. Install and configure* 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/>`_.

..

*** 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``:

.. code:: 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.
2 changes: 2 additions & 0 deletions graphene_django/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@
# 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:
Expand Down
97 changes: 88 additions & 9 deletions graphene_django/static/graphene_django/graphiql.js
Original file line number Diff line number Diff line change
@@ -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=');
Expand All @@ -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))] =
Expand Down Expand Up @@ -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'
Expand All @@ -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) {
Expand All @@ -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;
Expand All @@ -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,
);
9 changes: 9 additions & 0 deletions graphene_django/templates/graphene/graphiql.html
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,19 @@
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/graphiql@{{graphiql_version}}/graphiql.min.js"
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/subscriptions-transport-ws@{{subscriptions_transport_ws_version}}/browser/client.js"
crossorigin="anonymous"></script>
</head>
<body>
<div id="editor"></div>
{% 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>
</body>
</html>
11 changes: 9 additions & 2 deletions graphene_django/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,10 @@ def instantiate_middleware(middlewares):


class GraphQLView(View):
graphiql_version = "0.14.0"
graphiql_version = "1.0.3"
graphiql_template = "graphene/graphiql.html"
react_version = "16.8.6"
react_version = "16.13.1"
subscriptions_transport_ws_version = "0.9.16"

schema = None
graphiql = False
Expand All @@ -64,6 +65,7 @@ class GraphQLView(View):
root_value = None
pretty = False
batch = False
subscription_path = None

def __init__(
self,
Expand All @@ -75,6 +77,7 @@ def __init__(
pretty=False,
batch=False,
backend=None,
subscription_path=None,
):
if not schema:
schema = graphene_settings.SCHEMA
Expand All @@ -97,6 +100,8 @@ def __init__(
self.graphiql = self.graphiql or graphiql
self.batch = self.batch or batch
self.backend = backend
if subscription_path is None:
subscription_path = graphene_settings.SUBSCRIPTION_PATH

assert isinstance(
self.schema, GraphQLSchema
Expand Down Expand Up @@ -134,6 +139,8 @@ def dispatch(self, request, *args, **kwargs):
request,
graphiql_version=self.graphiql_version,
react_version=self.react_version,
subscriptions_transport_ws_version=self.subscriptions_transport_ws_version,
subscription_path=self.subscription_path,
)

if self.batch:
Expand Down