GraphQL subscriptions in Django with Ariadne and Channels
WebSockets are mostly associated with the asynchronous capabilities of JavaScript engines. But it doesn't have to be JavaScript all the way down.
The Python asynchronous ecosystem has become robust and stable enough in recent years to offer a solid ground for real-time projects.
In this post we will see how to add GraphQL subscriptions to Django with Ariadne and Channels, and what it takes to work with the Django ORM in an asynchronous context.
What is Ariadne?
Ariadne is a GraphQL library for Python, which offers also an integration for Django.
GraphQL is a data query language which allows the client to precisely define what data to fetch from the server and combine data from multiple resources in one request. In a sense, this is what we always did with REST APIs, but GraphQL takes this a step further, pushing more control to the client.
What is Django Channels?
Channels is a library which adds, among other protocols, WebSockets capabilities to Django.
Setup and sample models
This mini tutorial assumes you have a Django project where you can install Django Channels and Ariadne.
My demo project has a sample model named Order
, as follows:
from django.db import models
from django.conf import settings
class Order(models.Model):
class State(models.TextChoices):
PAID = "PAID"
UNPAID = "UNPAID"
date = models.DateField()
state = models.CharField(max_length=6, choices=State.choices, default=State.UNPAID)
user = models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
To start off, with the virtual Python environment active, install Channels and Ariadne:
pip install ariadne channels
Next up, configure INSTALLED_APPS
to load both apps, and ASGI_APPLICATION
:
INSTALLED_APPS = [
...
"ariadne.contrib.django",
"channels",
]
ASGI_APPLICATION = "django_project.asgi.application"
Once this is done, open up asgi.py
, which in a stock Django project is located in django_project/asgi.py
(where django_project
is my sample project), and configure the ASGI application so that it responds with URLRouter
from Channels:
import os
from django.core.asgi import get_asgi_application
from ariadne.asgi import GraphQL
from channels.routing import URLRouter
from django.urls import path
from orders.schema import schema
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "django_project.settings")
application = URLRouter(
[
path("graphql/", GraphQL(schema)),
path("", get_asgi_application())
]
)
In the URLRouter
configuration we load two routes. First:
path("graphql/", GraphQL(schema))
which is the Ariadne ASGI application. It handles both HTTP, for GraphQL queries, and WebSockets, once the connection is established after a successful subscription request.
Next up there is:
path("", get_asgi_application())
which handles any other request to Django, for example /admin
, or other non-GraphQL routes.
Note: Ariadne documentation suggests to use AsgiHandler
from channels.http
instead of get_asgi_application()
, but AsgiHandler
has been deprecated in favor of the latter.
Schema and imports
With the configuration and the URLs in place, we can set up the schema, and the Ariadne machinery.
For the scope of this post we build the schema in the app folder at orders/schema.py
. The following code shows how to declare the schema in Ariadne, and what modules we need to import:
import asyncio
from ariadne import gql, make_executable_schema
from ariadne import SubscriptionType
from ariadne.contrib.django.scalars import date_scalar
from orders.models import Order
type_defs = gql(
"""
scalar Date
enum OrderState {
PAID
UNPAID
}
type User {
username: String
email: String
}
type Order {
date: Date
state: OrderState
user: User
}
type Query {
getOrders: [Order]
}
type Subscription {
getOrder: Order
}
"""
)
As you can see from the schema, we define a root Query
type, which is mandatory, but we don't declare a resolver to handle it. This is intended for the purpose of this tutorial since we want to focus only on subscriptions.
In fact, we can see a Subscription
root type, which for now declares just one field:
type Subscription {
getOrder: Order
}
This declaration makes possible to handle the following query sent from the client:
subscription getLastOrder {
getOrder {
state
date
}
}
With the schema in place, we can move to define a resolver in order to satisfy the subscription.
Subscriptions, generators, and resolvers
To satisfy a subscription in Ariadne, we need to define two functions:
- a asynchronous generator, in charge of fetching the actual data from the database
- a resolver, in charge of returning data to the client
A generator function in Python is a function which can be paused and resumed at will. The concept is not so different from JavaScript generator functions.
Generator functions return simple values to the caller, and work synchronously. Asynchronous generator function instead work asynchronously, and return an asynchronous generator object.
This kind of objects can be iterated over with async for
in Python. Again, the concept is similar to asynchronous generator functions in JavaScript, and if you're a JavaScript developer the theory overlaps perfectly.
What matter for us here, is that Ariadne wants an asynchronous generator function to fetch data from the database, and another resolver function which basically sends back data to the client.
The following example shows how to wire up the two functions:
"""
Your imports and the schema here ...
"""
subscription = SubscriptionType()
@subscription.source("getOrder")
async def generate_order(obj, info):
"""Disclaimer: for demo purpose only."""
while True:
await asyncio.sleep(1)
# TODO: get the data from the database
@subscription.field("getOrder")
def resolve_order(order, info):
return order
schema = make_executable_schema(type_defs, [date_scalar], subscription)
Here we have an asynchronous generator decorated with @subscription.source()
, and a resolver decorated with @subscription.field()
.
On the last line, we wire up the schema, the Date
scalar, and the subscription:
schema = make_executable_schema(type_defs, [date_scalar], subscription)
This schema is now ready to be picked up by django_project/asgi.py
.
Now that we have the big picture, let's focus on the interaction between the asynchronous world and the Django ORM.
Working asynchronously with the Django ORM
Since the arrival of asynchronous views in Django, it has been stressed over and over that while Django is gaining asynchronous capabilities, the ORM is still synchronous.
What this means is that the following code is wrong:
@subscription.source("getOrder")
async def generate_order(obj, info):
"""Disclaimer: for demo purpose only."""
while True:
await asyncio.sleep(1)
"""Wrong!"""
yield Order.objects.last()
If we try to access the ORM from an asynchronous context, Django screams to us:
"You cannot call this from an async context - use a thread or sync_to_async."
You can test this out by heading over http://127.0.0.1:8000/graphql/
with the Django project running in development, and by sending out a subscription request:
To make the ORM work in an asynchronous context, in the latest versions of Django we can peruse sync_to_async
from asgiref.sync
.
Here, since we work with Django Channels, we need to use database_sync_to_async
, a small wrapper around asgiref.sync.sync_to_async
.
The following example shows how to interact with the ORM from an asynchronous context:
...
from channels.db import database_sync_to_async
...
...
@subscription.source("getOrder")
async def generate_order(obj, info):
"""Disclaimer: for demo purpose only."""
while True:
await asyncio.sleep(1)
yield await database_sync_to_async(lambda: Order.objects.last())()
...
This change will make the subscription work:
Working with related field asynchronously with the Django ORM
We made Django work with Ariadne and Channels for a simple subscription:
subscription getLastOrder {
getOrder {
state
date
}
}
However, things start to become tricky if we try to reach for the foreign key relationship, like in the following subscription:
subscription getLastOrder {
getOrder {
state
date
user {
username
email
}
}
}
By launching this subscription, the Django ORM will raise the same exception as in our first experiment:
"You cannot call this from an async context - use a thread or sync_to_async."
This time, the error comes from the user
field access. What can we do about it?
To force the foreign key query evaluation in the asynchronous context, we can use .select_related()
from the Django ORM, as in the following example:
@subscription.source("getOrder")
async def generate_order(obj, info):
"""Disclaimer: for demo purpose only."""
while True:
await asyncio.sleep(1)
yield await database_sync_to_async(
lambda: Order.objects.select_related("user").last()
)()
With Order.objects.select_related("user").last()
we issue just one slightly bigger query, forcing Django to fetch both the Order
, and the related user in the asynchronous context.
Subscriptions in the frontend, and CORS
To consume GraphQL subscriptions in the frontend, Apollo client is the way to go. The documentation has a complete example of how to make this happen.
Basically, you will need HttpLink
and WebSocketLink
, and for React, you can use useSubscription()
.
As for the backend, if the frontend lives on a different origin than the GraphQL API, you will need to enable CORS.
Out of the box, Ariadne offers no CORS support, but this is easily solvable with Starlette's CORSMiddleware
as shown in the following example from django_project/asgi.py
:
...
from starlette.middleware.cors import CORSMiddleware
...
application = URLRouter(
[
path(
"graphql/",
CORSMiddleware(
GraphQL(schema),
allow_origins=["https://first-origin.io/"],
allow_methods=["*"]
),
),
path("", get_asgi_application()),
]
)
Hope you learned something new today. Thanks for reading!