A Guide to Schema-First GraphQL with Django and Ariadne
I recently stumbled my way though integrating Django with Ariadne and am going to share the lessons learned here. We'll cover the following topics:
- Setting up Django with Ariadne
- Hooking up resolvers to our Django models
- Modularizing our schema and resolvers
- Writing tests
- Dates, and Datetimes
- Authentication
- Accessing the User object
Setting up Django with Ariadne
To start we need to install Django and Ariadne and create an empty Django project:
> pip install django ariadne
> django-admin startproject ariadne_tutorial
To get the GraphQL server working, there's only two things we need to do. First we need to modify the INSTALLED_APPS in our settings file.
INSTALLED_APPS = [
...
"ariadne.contrib.django",
]
Then we need to modify our urls file to include
Ariadne's GraphQLView
with a placeholder schema:
from ariadne import gql, QueryType, make_executable_schema
from ariadne.contrib.django.views import GraphQLView
from django.contrib import admin
from django.urls import path
type_defs = gql("""
type Query {
hello: String!
}
""")
def resolve_hello(*_):
return "Hello!"
query = QueryType()
query.set_field("hello", resolve_hello)
schema = make_executable_schema(type_defs, query)
urlpatterns = [
path("admin/", admin.site.urls),
path("graphql/", GraphQLView.as_view(schema=schema)),
]
Now if you run ./manage.py runserver
and paste
localhost:8000/graphql
into the browser, you'll
see the graphql playground:
Hooking up resolvers to our Django models
Let's make our example more realistic by
creating resolvers that actually
interact with a Django model. To do this we're
going to need to create a Django app and add
a model in the app's models.py
:
> django-admin startapp books
from django.db import models
class Book(models.Model):
title = models.CharField(max_length=100)
We'll start with two GraphQL operations:
a query books
and a mutation createBook
.
Let's modify our urls file with the new schema
and resolvers:
from ariadne import gql, QueryType, make_executable_schema
from ariadne.contrib.django.views import GraphQLView
from django.contrib import admin
from django.urls import path
type_defs = gql("""
type Query {
books: [Book!]!
}
type Mutation {
createBook: Book!
}
type Book {
title: String!
}
""")
def list_books(*_):
return {
"title": book.title,
for book in Book.objects.all()
}
def create_book(*_, title):
book = Book.objects.create(titie=title)
return {"title": book.title}
query = QueryType()
query.set_field("books", list_books)
mutation = MutationType()
mutation.set_field("createBooks", create_book)
schema = make_executable_schema(type_defs, query, mutation)
urlpatterns = [
path("admin/", admin.site.urls),
path("graphql/", GraphQLView.as_view(schema=schema)),
]
Now if we visit the graphql playground again, we are able to create and list books:
Modularizing our schema and resolvers
Putting everything in our top-level urls file
works fine for now, but this isn't going to scale.
It would be better to move our schema
into .graphql
files
that live in the Django apps they relate to.
Secondly, we want to move our GraphQL config out of the top-level urls.py file
and into a dedicated graphql_config.py
.
Let's start by moving our schema and resolvers related to book
into the books app:
type Book {
title: String!
}
from .models import Book
def list_books(*_):
return [
{"title": book.title}
for book in Book.objects.all()
]
def create_book(*_, title):
book = Book.objects.create(title=title)
return {"title": book.title}
Our Query and Mutation types will live in a top-level schema file:
type Query {
books: [Book!]!
}
type Mutation {
createBook(title: String!): Book!
}
And our GraphQL config will be moved out of the "urls.py" file into a dedicated "graphql_config.py".
from ariadne import QueryType, make_executable_schema, load_schema_from_path, MutationType
import books.resolvers
type_defs = [
load_schema_from_path("ariadne_tutorial/schema.graphql"),
load_schema_from_path("books/schema.graphql"),
]
query = QueryType()
query.set_field("books", books.resolvers.list_books)
mutation = MutationType()
mutation.set_field("createBook", books.resolvers.create_book)
schema = make_executable_schema(type_defs, query, mutation)
Now our urls file can be simplified to look like this:
from ariadne.contrib.django.views import GraphQLView
from django.contrib import admin
from django.urls import path
from .graphql_config import schema
urlpatterns = [
path("admin/", admin.site.urls),
path("graphql/", GraphQLView.as_view(schema=schema)),
]
Writing tests
We can test our GraphQL operations the same way
we'd test any other Django view. GraphQL operations are sent
as POST requests to the /graphql
endpoint. The query/mutation is passed as a string in the
JSON payload. As an example, here are tests for our
createBook
mutation
and our books
query:
from django.test import TestCase, Client
from books.models import Book
class GraphQLTest(TestCase):
def test_list_books(self):
Book.objects.create(title="Book Title")
query = """
query {
books {
title
}
}
"""
response = Client().post(
"/graphql/",
{"query": query},
content_type="application/json"
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json().get("errors"), None)
books = response.json()["data"]["books"]
self.assertEqual(len(books), 1)
self.assertEqual(books[0]["title"], "Book Title")
def test_create_book(self):
mutation = """
mutation createBook($title: String!) {
createBook(title: $title) {
title
}
}
"""
response = Client().post(
"/graphql/",
{"query": mutation, "variables": {"title": "Book Title"}},
content_type="application/json"
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json().get("errors"), None)
self.assertEqual(response.json()["data"]["createBook"]["title"], "Book Title")
Converting between camel case and snake case
The JavaScript convention is to use camel case, while
in python we use snake case. Ariadne provides a
way to automatically convert between these two naming
conventions. To make this work we have to tell Ariadne to use the
snake_case_fallback_resolvers
in our graphql config. This default resolver will map our resolver return values
from snake case to camel case:
from ariadne import ... , snake_case_fallback_resolvers
...
schema = make_executable_schema(... , snake_case_fallback_resolvers)
We'd also like to automatically convert our query/mutation arguments from camel case
to snake case (i.e. the keyword arguments that get passed
to our resolvers). For this Ariadne provides a decorator
convert_kwargs_to_snake_case
that we can wrap our resolvers with:
from ariadne import convert_kwargs_to_snake_case
...
@convert_kwargs_to_snake_case
def list_books(*_):
...
@convert_kwargs_to_snake_case
def create_book(*_, title: str):
...
Let's demonstrate by adding a published_at
field to our model and resolvers.
from django.db import models
class Book(models.Model):
title = models.CharField(max_length=100)
published_at = models.DateTimeField(null=True, default=None)
from ariadne import convert_kwargs_to_snake_case
from .models import Book
@convert_kwargs_to_snake_case
def list_books(*_):
return [
{"title": book.title, "published_at": book.published_at}
for book in Book.objects.all()
]
@convert_kwargs_to_snake_case
def create_book(*_, title: str, published_at: str):
book = Book.objects.create(title=title, published_at=published_at)
return {"title": book.title}
In our GraphQL schemas, we're going to use the camel
case version
publishedAt
:
type Book {
title: String!
publishedAt: String
}
type Query {
books: [Book!]!
}
type Mutation {
createBook(title: String!, publishedAt: String): Book!
}
Dates and Datetimes
Ariadne provides scalar mappings between
ISO formatted strings and python datetime/date objects.
This allows us to avoid manually converting
between strings and datetime objects, and makes sure
that they are converted to a string format readable by
common javascript libraries like moment.js. First, we need
to add the DateTime
and
Date
scalars to our schema:
scalar DateTime
scalar Date
type Book {
title: String!
publishedAt: DateTime!
}
type Query {
books: [Book!]!
}
type Mutation {
createBook(title: String!, publishedAt: DateTime): Book!
}
Now, we need to tell Ariadne how to convert our scalars
to and from python objects by including
date_scalar
and
datetime_scalar
in our schema:
...
from ariadne.contrib.django.scalars import date_scalar, datetime_scalar
...
schema = make_executable_schema(
type_defs,
query,
mutation,
date_scalar,
datetime_scalar,
snake_case_fallback_resolvers,
)
Authorization
If you are using GraphQL as the backend server for a
web app like me, then you probably only want users
who are logged in to be able to use the
/graphql
endpoint.
The easiest way to do this is to wrap the GraphQL view
with Django's @login_required
decorator.
...
from django.contrib.auth.decorators import login_required
...
urlpatterns = [
path("admin/", admin.site.urls),
path("graphql/", login_required(GraphQLView.as_view(schema=schema))),
]
The login_required decorator will attempt redirect the use to the login page with a 302 if they aren't authenticated. Apollo client won't follow the redirect by default, so this is something you'll have to configure client side.
Accessing the User object
Ariadne provides access to the user object through the second positional argument, the "info" object, in each resolver:
def example_resolver(_, info):
user = info.context.user
We can modify our
list_books
resolver to only return
books associated with the authenticated user like this:
...
def list_books(_, info):
return [
{"title": book.title, "published_at": book.published_at}
for book in Book.objects.filter(user=info.context.user)
]
...
In a normal HTTP request, Django's
AuthenticationMiddleware
puts the
user on the request object. Since we're using graphql
over HTTP, each graphql operation is carried in an HTTP
request that passes through all the same middleware. That's
how the user object gets populated.
Closing remarks
I hope this was helpful :)