May 9, 2022

Adding GraphQL to your Django App

Cover image

Adding a GraphQL endpoint to your Django application is a simple and powerful way to reduce API maintenance and give more power to your frontend developers.

There are a variety of choices when it comes to libraries to assist you in adding GraphQL support to your application. The two main contenders are Graphene and Strawberry, both of which having specific Django extensions (graphene-django and strawberry-graphql-django) that use introspection on your models in building out the types for the API.

Graphene vs. Strawberry

Graphene has been around for a while and works well. There does seem to be a maintenance issue as it has been awhile since a new release has landed though there is recent activity in the repo.

Strawberry on the other hand is a new comer with active development and releases and has commercial support. I find the API more pleasant in Strawberry as well though my experience with it is still too limited to have a strong opinion on which is best.

With both libraries you build up a set of Types that you want to access through Queries and manipulate through Mutations. Therefore, let's just do a quick side by side comparison of how you build up a GraphQL service for the following data model:

# A specific weightlifting movement, e.g. Bench Press, Squat, Clean
class Movement(models.Model):
    name = models.CharField(max_length=100)
    slug = models.SlugField(unique=True)


# Maximum reps of a weight of a Movement on a certain date
class Lift(models.Model):
    lifter = models.ForeignKey(User, on_delete=models.CASCADE)
    movement = models.ForeignKey(Movement, on_delete=models.CASCADE)
    reps = models.IntegerField()
    pounds = models.IntegerField()
    lifted_on = models.DateField()
    created_at = models.DateTimeField(default=timezone.now)

    def calculate_max(self, reps: int) -> int:
        ...

Types

With Graphene we'd have:

import graphene
from graphene_django import DjangoObjectType


class UserType(DjangoObjectType):
    class Meta:
        model = get_user_model()


class MovementType(DjangoObjectType):
    class Meta:
        model = Movement


class LiftType(DjangoObjectType):
    one_rep_max = graphene.Int()

    class Meta:
        model = Lift

    def resolve_one_rep_max(self, info) -> int:
        return self.calculate_max(1)

And with Strawberry there is:

import strawberry_django
from strawberry_django import


@strawberry_django.type(get_user_model())
class UserType:
    username: auto
    email: auto


@strawberry_django.type(Movement)
class MovementType:
    id: auto
    name: auto
    slug: auto


@strawberry_django.type(Lift)
class LiftType:
    id: auto
    movement: MovementType
    reps: auto
    pounds: auto
    lifted_on: auto
    created_at: auto

    @strawberry_django.field
    def one_rep_max(self) -> int:
        return self.calculate_max(1)

Queries and Mutations

With both Graphene and Strawberry you are building up a Schema object that consists of a Query and/or Mutation object that they themselves contain a set of methods that resolve into Types previously defined.

Building our our types, let's define some queries that allows us to query for Lifts as well as Movements and mutations that allow us to create, update and delete Lifts.

First in Graphene we'd have:

import typing

import graphene

from . import models
from .types import LiftType, MovementType


class Query(graphene.ObjectType):
    movements = graphene.List(MovementType)
    lifts = graphene.List(LiftType)

    def resolve_lifts(self, info, *args, **kwargs) -> list[LiftType]:
        return LiftType.get_queryset(
            models.Lift.objects.filter(lifter=info.context.user),
            info
        )


class LiftCreateMutation(graphene.Mutation):
    class Arguments:
        reps = graphene.Int(required=True)
        pounds = graphene.Int(required=True)
        lifted_on: = graphene.Date(required=True)
        movement_slug = graphene.String(required=True)

    ok = graphene.Boolean()
    lift = graphene.Field(LiftType)
    error = graphene.String()

    @classmethod
    def mutate(
        cls, root, info, reps: int, pounds: int, lifted_on: str, movement_slug: str, **kwargs
    ) -> "LiftCreateMutation":
        movement = models.Movement.objects.filter(slug=movement_slug).first()
        if movement is None:
            raise cls(ok=False, error="Movement doesn't exist.")

        lift = movement.lifts.create(
            lifter=info.context.user,
            reps=reps,
            pounds=pounds,
            lifted_on=lifted_on
        )
        return cls(ok=True, lift=lift)


class LiftUpdateMutation(graphene.Mutation):
    class Arguments:
        id = graphene.Int(required=True)
        reps = graphene.Int(required=True)
        pounds = graphene.Int(required=True)
        lifted_on: = graphene.Date(required=True)
        movement_slug = graphene.String(required=True)

    ok = graphene.Boolean()
    lift = graphene.Field(LiftType)
    error = graphene.String()

    @classmethod
    def mutate(
        cls, root, info, reps: int, pounds: int, lifted_on: str, movement_slug: str, **kwargs
    ) -> "LiftUpdateMutation":
        lift = models.Lift.objects.filter(
            lifter=info.context.user,
            id=id
        ).first()
        if lift is None:
            return cls(ok=False, error="Lift doesn't exist.")

        movement = models.Movement.objects.filter(slug=movement_slug).first()
        if movement is None:
            return cls(ok=False, error="Movement doesn't exist.")

        lift.movement = movement
        lift.reps = reps
        lift.pounds = pounds
        lift.lifted_on = lifted_on
        lift.save(update_fields=["movement", "reps", "pounds", "lifted_on"])

        return cls(ok=True, lift=lift)


class LiftDeleteMutation(graphene.Mutation):
    class Arguments:
        id = graphene.Int(required=True)

    ok = graphene.Boolean()
    error = graphene.String()

    @classmethod
    def mutate(
        cls, root, info, reps: int, pounds: int, lifted_on: str, movement_slug: str, **kwargs
    ) -> "LiftDeleteMutation":
        lift = models.Lift.objects.filter(
            lifter=info.context.user,
            id=id
        ).first()
        if lift is None:
            raise cls(ok=False, error="Lift doesn't exist.")

        lift.delete()

        return cls(ok=True)


class Mutation(graphene.ObjectType):
    lift_create = LiftCreateMutation.Field()
    lift_update = LiftUpdateMutation.Field()
    lift_delete = LiftDeleteMutation.Field()


schema = graphene.Schema(query=Query, mutation=Mutation)

And in Strawberry it would look like:

import typing
from datetime import date

import strawberry
import strawberry_django

from . import models
from .types import LiftType, MovementType


if typing.TYPE_CHECKING:  # pragma: no cover
    from typing import Any
    from django.http import HttpRequest, JsonResponse
    from strawberry.types import Info


class IsAuthenticated(strawberry.BasePermission):
    message = "User is not authenticated"

    def has_permission(self, source: "Any", info: "Info", **kwargs) -> bool:
        request: "HttpRequest" = info.context["request"]
        return request.user.is_authenticated


@strawberry.type
class Query:
    movements: list[MovementType] = strawberry_django.field()

    @strawberry.field(permission_classes=[IsAuthenticated])
    def lifts(self, info: "Info") -> list[LiftType]:
        return list(
            models.Lift.objects.filter(lifter=info.context.request.user)
        )


@strawberry.type
class Mutation:

    @strawberry.mutation(permission_classes=[IsAuthenticated])
    def lift_create(
        self,
        reps: int,
        pounds: int,
        lifted_on: date,
        movement_slug: str,
        info: "Info",
    ) -> LiftType:
        movement = models.Movement.objects.filter(slug=movement_slug).first()
        if movement is None:
            raise Exception("Movement doesn't exist.")

        return movement.lifts.create(
            lifter=info.context.request.user,
            reps=reps,
            pounds=pounds,
            lifted_on=lifted_on
        )

    @strawberry.mutation(permission_classes=[IsAuthenticated])
    def lift_update(
        self,
        id: int,
        reps: int,
        pounds: int,
        lifted_on: date,
        movement_slug: str,
        info: "Info",
    ) -> LiftType:
        lift = models.Lift.objects.filter(
            lifter=info.context.request.user,
            id=id
        ).first()
        if lift is None:
            raise Exception("Lift doesn't exist.")
        movement = models.Movement.objects.filter(slug=movement_slug).first()
        if movement is None:
            raise Exception("Movement doesn't exist.")

        lift.movement = movement
        lift.reps = reps
        lift.pounds = pounds
        lift.lifted_on = lifted_on
        lift.save(update_fields=["movement", "reps", "pounds", "lifted_on"])

        return lift

    @strawberry.mutation(permission_classes=[IsAuthenticated])
    def lift_delete(self, id: int, info: "Info") -> str:
        lift = models.Lift.objects.filter(
            lifter=info.context.request.user,
            id=id
        ).first()
        if lift is None:
            raise Exception("Lift doesn't exist.")

        lift.delete()

        return "ok"


schema = strawberry.Schema(query=Query, mutation=Mutation)

Exposing the Endpoint

Now that we have our schema instances we need to expose them the web. Both Graphene and Strawberry pretty much do the same thing here, passing the schema object into a prebuilt Django view that handles all the nitty-gritty of GraphQL requests and respones.

In Graphene:

from django.conf import settings
from django.urls import path
from django.views.decorators.csrf import csrf_exempt

from graphene_django.views import GraphQLView

from .schema import schema


urlpatterns = [
    path("graphql/", csrf_exempt(
        GraphQLView.as_view(graphiql=settings.DEBUG, schema=schema)
    )),
]

In Strawberry:

from django.conf import settings
from django.urls import path

from strawberry.django.views import GraphQLView

from .schema import schema


urlpatterns = [
    # Strawberry's GraphQLView pre-bakes the csrf_exempt decorator so you
    # do not need to add it like we do for Graphene
    path("graphql/", GraphQLView.as_view(
        graphiql=settings.DEBUG,
        schema=schema,
    ))
]

What do you think? Are you building anything with GraphQL in Django? I'd love to hear more about what you doing and if it's different than either of these approaches.

You can find me on Twitter.