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.

ariadne_tutorial/settings.py
INSTALLED_APPS = [
    ...
    "ariadne.contrib.django",
]

Then we need to modify our urls file to include Ariadne's GraphQLView with a placeholder schema:

ariadne_tutorial/urls.py
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:

graphql playground image

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

books/models.py
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:

ariadne_tutorial/urls.py
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:

graphql playground image

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:

books/schema.graphql
type Book {
    title: String!
}

books/resolvers.py
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:

ariadne_tutorial/schema.graphql
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".

ariadne_tutorial/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:

ariadne_tutorial/graphql_config.py
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:

books/tests/test_resolvers
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:

ariadne_tutorial/graphql_config.py
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:

books/resolvers.py
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.

books/models.py
from django.db import models

class Book(models.Model):
    title = models.CharField(max_length=100)
    published_at = models.DateTimeField(null=True, default=None)

books/resolvers.py
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:

books/schema.graphql
type Book {
    title: String!
    publishedAt: String
}

ariadne_tutorial/schema.graphql
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:

books/schema.graphql
scalar DateTime
scalar Date

type Book {
    title: String!
    publishedAt: DateTime!
}

ariadne_tutorial/schema.graphql
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:

ariadne_tutorial/graphql_config.py
...
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.

ariadne_tutorial/urls.py
...
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:

books/resolvers.py
...
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 :)