Tutorial๐Ÿ”—

Before starting the tutorial, read the Getting Started section.

This tutorial will guide you through creating a simple GraphQL API using Undine. You'll learn the fundamental aspects of creating a GraphQL schema: queries, mutations, filtering, ordering, permissions, and validation. This should give you familiarity with how Undine works so that you can explore the rest of the documentation for more details.

The example application will be a project management system, where users can create tasks with multiple steps and add them to projects. Very exciting! The Django project will have a single app called service where you'll create your models and schema. See the full directory structure below:

system/
โ”œโ”€ config/
โ”‚  โ”œโ”€ __init__.py
โ”‚  โ”œโ”€ settings.py
โ”‚  โ”œโ”€ urls.py
โ”‚  โ”œโ”€ wsgi.py
โ”œโ”€ service/
โ”‚  โ”œโ”€ migrations/
โ”‚  โ”‚  โ”œโ”€ __init__.py
โ”‚  โ”œโ”€ __init__.py
โ”‚  โ”œโ”€ apps.py
โ”‚  โ”œโ”€ models.py
โ”œโ”€ manage.py

A starting template is available in docs/snippets/tutorial/template.

Part 1: Setup๐Ÿ”—

First, install Undine using the installation instructions.

Undine comes with an example schema that you can try out before creating your own. To access it, add the following to your project's urls.py file:

1
2
3
4
5
from django.urls import include, path

urlpatterns = [
    path("", include("undine.http.urls")),
]

Next, configure Undine to enable GraphiQL, a tool for exploring GraphQL schemas in the browser. Undine is configured using the UNDINE setting in your Django project's settings.py file, so add the following to it:

1
2
3
4
UNDINE = {
    "GRAPHIQL_ENABLED": True,
    "ALLOW_INTROSPECTION_QUERIES": True,
}

Now start the Django server and navigate to /graphql/ to see the GraphiQL UI. Make the following request:

1
2
3
query {
  testing
}

You should see the following response:

1
2
3
4
5
{
  "data": {
    "testing": "Hello World"
  }
}

Part 2: Creating the Schema๐Ÿ”—

Next, let's replace the example schema with your own. Create a file called schema.py in your service app directory, and add the following to it:

from undine import Entrypoint, RootType, create_schema


class Query(RootType):
    @Entrypoint
    def testing(self) -> str:
        return "Hello World"


schema = create_schema(query=Query)

This code creates the same schema as Undine's example schema. To make it your own, simply modify the return value of the testing method with your own custom message.

In Undine, Entrypoints are used in the class bodies of RootTypes to define the operations that can be executed at the root of the GraphQL schema.

Now you need to tell Undine to use your custom schema instead of the example one. Add the SCHEMA setting to Undine's configuration and set it to point to the schema variable you created in your schema.py file.

1
2
3
4
5
UNDINE = {
    "GRAPHIQL_ENABLED": True,
    "ALLOW_INTROSPECTION_QUERIES": True,
    "SCHEMA": "service.schema.schema",
}
How do I determine the value for SCHEMA?

The value for SCHEMA is a "dotted import path" โ€” a string that can be imported with Django's import_string utility. In other words, "service.schema.schema" points to a file service/schema.py with a variable schema.

Restart the Django server and make the same request as before. You should see your own message instead of the example one.


Part 3: Adding Queries๐Ÿ”—

Now that you have your own schema, let's start exposing Django models through it. In your models.py file, add the following model:

1
2
3
4
5
6
7
from django.db import models


class Task(models.Model):
    name = models.CharField(max_length=255)
    done = models.BooleanField(default=False)
    created_at = models.DateTimeField(auto_now_add=True)

Create and run migrations for this model.

To add the Task model to the schema, let's add two Entrypoints: one for fetching a single Task, and another for fetching all Tasks. Replace the current schema.py file with the following:

from undine import Entrypoint, Field, QueryType, RootType, create_schema

from .models import Task


class TaskType(QueryType[Task]):
    pk = Field()
    name = Field()
    done = Field()
    created_at = Field()


class Query(RootType):
    task = Entrypoint(TaskType)
    tasks = Entrypoint(TaskType, many=True)


schema = create_schema(query=Query)

A QueryType is a class that represents a GraphQL ObjectType for a Django model in the GraphQL schema. By adding Fields to its class body, you can expose the model's fields in the GraphQL schema.

To create Entrypoints for this QueryType, you simply use the QueryType as an argument to the Entrypoint class instead of decorating a method like you did before. This creates an Entrypoint for fetching a single Task by its primary key. For fetching all Tasks, pass many=True to indicate a list endpoint.

Now it's time to try out your new schema. But wait, first you need some data to query! In your terminal, run python manage.py shell to start Django's shell and create a few rows for the Task model.

1
2
3
4
>>> from service.models import Task
>>> Task.objects.create(name="Task 1", done=False)
>>> Task.objects.create(name="Task 2", done=True)
>>> Task.objects.create(name="Task 3", done=False)

Now reboot the Django server and make the following request:

1
2
3
4
5
6
7
query {
  tasks {
    pk
    name
    done
  }
}
You should see this response:
{
  "data": {
    "tasks": [
      {
        "pk": 1,
        "name": "Task 1",
        "done": false
      },
      {
        "pk": 2,
        "name": "Task 2",
        "done": true
      },
      {
        "pk": 3,
        "name": "Task 3",
        "done": false
      }
    ]
  }
}

Next, let's add a couple more models to your project.

from django.db import models


class Project(models.Model):
    name = models.CharField(max_length=255)


class Task(models.Model):
    name = models.CharField(max_length=255)
    done = models.BooleanField(default=False)
    created_at = models.DateTimeField(auto_now_add=True)

    project = models.ForeignKey(Project, on_delete=models.SET_NULL, null=True, blank=True, related_name="tasks")


class Step(models.Model):
    name = models.CharField(max_length=255)
    done = models.BooleanField(default=False)

    task = models.ForeignKey(Task, on_delete=models.CASCADE, related_name="steps")

Create and run migrations for these models, then create some data for them:

>>> from service.models import Project, Step, Task
>>> project_1 = Project.objects.create(name="Project 1")
>>> project_2 = Project.objects.create(name="Project 2")
>>> task_1 = Task.objects.get(name="Task 1")
>>> task_2 = Task.objects.get(name="Task 2")
>>> task_3 = Task.objects.get(name="Task 3")
>>> task_1.project = project_1
>>> task_1.save()
>>> task_2.project = project_2
>>> task_2.save()
>>> step_1 = Step.objects.create(name="Step 1", done=false, task=task_1)
>>> step_2 = Step.objects.create(name="Step 2", done=true, task=task_1)
>>> step_3 = Step.objects.create(name="Step 3", done=false, task=task_2)
>>> step_4 = Step.objects.create(name="Step 4", done=true, task=task_3)
>>> step_5 = Step.objects.create(name="Step 5", done=true, task=task_3)

Then, add these models to your schema by creating a QueryType for each of them. We can also link the QueryTypes to each other by adding Fields for the model's relations.

from undine import Entrypoint, Field, QueryType, RootType, create_schema

from .models import Project, Step, Task


class ProjectType(QueryType[Project]):
    pk = Field()
    name = Field()
    tasks = Field()


class TaskType(QueryType[Task]):
    pk = Field()
    name = Field()
    done = Field()
    created_at = Field()
    project = Field()
    steps = Field()


class StepType(QueryType[Step]):
    pk = Field()
    name = Field()
    done = Field()
    task = Field()


class Query(RootType):
    task = Entrypoint(TaskType)
    tasks = Entrypoint(TaskType, many=True)


schema = create_schema(query=Query)

Reboot the Django server once more and make the following request:

query {
  tasks {
    pk
    name
    done
    project {
      pk
      name
    }
    steps {
      pk
      name
      done
    }
  }
}
You should see this response:
{
  "data": {
    "tasks": [
      {
        "pk": 1,
        "name": "Task 1",
        "done": false,
        "project": {
          "pk": 1,
          "name": "Project 1"
        },
        "steps": [
          {
            "pk": 1,
            "name": "Step 1",
            "done": false
          },
          {
            "pk": 2,
            "name": "Step 2",
            "done": true
          }
        ]
      },
      {
        "pk": 2,
        "name": "Task 2",
        "done": true,
        "project": {
          "pk": 2,
          "name": "Project 2"
        },
        "steps": [
          {
            "pk": 3,
            "name": "Step 3",
            "done": false
          }
        ]
      },
      {
        "pk": 3,
        "name": "Task 3",
        "done": false,
        "project": null,
        "steps": [
          {
            "pk": 4,
            "name": "Step 4",
            "done": true
          },
          {
            "pk": 5,
            "name": "Step 5",
            "done": true
          }
        ]
      }
    ]
  }
}

Now that you're are using relations, Undine will automatically optimize the database queries for those relations.


Part 4: Adding Mutations๐Ÿ”—

Next, let's add a mutation to your schema for creating Tasks. Add the following to the schema.py file:

from undine import Entrypoint, Field, Input, MutationType, QueryType, RootType, create_schema

from .models import Task


class TaskType(QueryType[Task]):
    pk = Field()
    name = Field()
    done = Field()
    created_at = Field()


class Query(RootType):
    task = Entrypoint(TaskType)
    tasks = Entrypoint(TaskType, many=True)


class TaskCreateMutation(MutationType[Task]):
    name = Input()
    done = Input()


class Mutation(RootType):
    create_task = Entrypoint(TaskCreateMutation)


schema = create_schema(query=Query, mutation=Mutation)

Undine will know that the MutationType TaskCreateMutation is a create mutation because the class has the word "create" in its name. Similarly, having "update" in the name will create an update mutation, and "delete" will create a delete mutation. Create, update and delete mutations are executed differently, more on this in the Mutations section.

You could also use the kind argument in the MutationType class definition to be more explicit.

1
2
3
4
5
6
7
8
from undine import Input, MutationType

from .models import Task


class TaskCreateMutation(MutationType[Task], kind="create"):
    name = Input()
    done = Input()

Undine will use the TaskType QueryType as the output type for MutationTypes automatically since they share the same model. All mutations require a QueryType for the same model to be created, even if it's not otherwise usable from the GraphQL schema.

Let's try out the new mutations. Boot up the Django server and make the following request:

1
2
3
4
5
mutation {
  createTask(input: {name: "New task"}) {
    name
  }
}
You should see this response:
1
2
3
4
5
6
7
{
  "data": {
    "createTask": {
      "name": "New task"
    }
  }
}

You can also mutate related objects by using other MutationTypes as Inputs. Modify the TaskCreateMutation by adding a Project Input.

from undine import Entrypoint, Field, Input, MutationType, QueryType, RootType, create_schema

from .models import Project, Task


class ProjectType(QueryType[Project]):
    pk = Field()
    name = Field()


class TaskType(QueryType[Task]):
    pk = Field()
    name = Field()
    done = Field()
    created_at = Field()
    project = Field()


class Query(RootType):
    task = Entrypoint(TaskType)
    tasks = Entrypoint(TaskType, many=True)


class TaskProjectInput(MutationType[Project], kind="related"):
    pk = Input()
    name = Input()


class TaskCreateMutation(MutationType[Task]):
    name = Input()
    done = Input()
    project = Input(TaskProjectInput)


class Mutation(RootType):
    create_task = Entrypoint(TaskCreateMutation)


schema = create_schema(query=Query, mutation=Mutation)

Here TaskProjectInput is a special "related" kind of MutationType. These MutationTypes allow you to freely modify the related objects during the mutation. For example, using the above configuration, you could create a Task and a Project in a single mutation.

mutation {
  createTask(
    input: {
      name: "New task"
      project: {
        name: "New project"
      }
    }
  ) {
    name
    project {
      name
    }
  }
}

Or you could link an existing Project to a new Task.

mutation {
  createTask(
    input: {
      name: "New task"
      project: {
        pk: 1
      }
    }
  ) {
    name
    project {
      name
    }
  }
}

Or link an existing Project while renaming it.

mutation {
  createTask(
    input: {
      name: "New task"
      project: {
        pk: 1
        name: "Renamed project"
      }
    }
  ) {
    name
    project {
      name
    }
  }
}

Undine also supports bulk mutations by using the many argument on the Entrypoint. Let's add a bulk mutation for creating Tasks using the TaskCreateMutation.

from undine import Entrypoint, Field, Input, MutationType, QueryType, RootType, create_schema

from .models import Project, Task


class ProjectType(QueryType[Project]):
    pk = Field()
    name = Field()


class TaskType(QueryType[Task]):
    pk = Field()
    name = Field()
    done = Field()
    created_at = Field()
    project = Field()


class Query(RootType):
    task = Entrypoint(TaskType)
    tasks = Entrypoint(TaskType, many=True)


class TaskProjectInput(MutationType[Project], kind="related"):
    pk = Input()
    name = Input()


class TaskCreateMutation(MutationType[Task]):
    name = Input()
    done = Input()
    project = Input(TaskProjectInput)


class Mutation(RootType):
    create_task = Entrypoint(TaskCreateMutation)
    bulk_create_tasks = Entrypoint(TaskCreateMutation, many=True)


schema = create_schema(query=Query, mutation=Mutation)

Bulk mutations work just like regular mutations. Boot up the Django server and make the following request:

mutation {
  bulkCreateTasks(
    input: [
      {
        name: "New Task"
        project: {
          name: "New Project"
        }
      }
      {
        name: "Other Task"
        project: {
          name: "Other Project"
        }
      }
    ]
  ) {
    name
    project {
      name
    }
  }
}
You should see this response:
{
  "data": {
    "bulkCreateTasks": [
      {
        "name": "New Task",
        "project": {
          "name": "New Project"
        }
      },
      {
        "name": "Other Task",
        "project": {
          "name": "Other Project"
        }
      }
    ]
  }
}

Part 5: Adding Permissions๐Ÿ”—

In Undine, you can add permission checks to QueryTypes or MutationTypes as well as individual Fields or Inputs. Let's add a permission check for querying Tasks.

from undine import GQLInfo, QueryType
from undine.exceptions import GraphQLPermissionError

from .models import Task


class TaskType(QueryType[Task]):
    @classmethod
    def __permissions__(cls, instance: Task, info: GQLInfo) -> None:
        if info.context.user.is_anonymous:
            msg = "Need to be logged in to access Tasks."
            raise GraphQLPermissionError(msg)

Now all users need to be logged in to access Tasks through TaskType. Boot up the Django server and make the following request:

1
2
3
4
5
query {
  tasks {
    name
  }
}
You should see this response:
{
  "data": null,
  "errors": [
    {
      "message": "Need to be logged in to access Tasks.",
      "locations": [
        {
          "line": 2,
          "column": 3
        }
      ],
      "path": [
        "tasks"
      ],
      "extensions": {
        "status_code": 403,
        "error_code": "PERMISSION_DENIED"
      }
    }
  ]
}

The permission check will be called for each instance returned by the QueryType.

For Field permissions, you first need to define a Field explicitly on the QueryType and then decorate a method with @<field_name>.permissions.

from undine import Field, GQLInfo, QueryType
from undine.exceptions import GraphQLPermissionError

from .models import Task


class TaskType(QueryType[Task]):
    name = Field()

    @name.permissions
    def name_permissions(self, info: GQLInfo, value: str) -> None:
        if info.context.user.is_anonymous:
            msg = "Need to be logged in to access the name of the Task."
            raise GraphQLPermissionError(msg)

Now users need to be logged in to be able to query Task names.

Mutation permissions work similarly to query permissions.

from typing import Any

from undine import GQLInfo, MutationType
from undine.exceptions import GraphQLPermissionError

from .models import Task


class TaskCreateMutation(MutationType[Task]):
    @classmethod
    def __permissions__(cls, instance: Task, info: GQLInfo, input_data: dict[str, Any]) -> None:
        if not info.context.user.is_staff:
            msg = "Must be a staff user to be able add tasks."
            raise GraphQLPermissionError(msg)

Now users need to be staff members to be able to create new Tasks using TaskCreateMutation.

You can also restrict the usage of specific Inputs by defining the input on the MutationType and decorating a method with @<input_name>.permissions.

from undine import GQLInfo, Input, MutationType
from undine.exceptions import GraphQLPermissionError

from .models import Task


class TaskCreateMutation(MutationType[Task]):
    done = Input()

    @done.permissions
    def done_permissions(self, info: GQLInfo, value: bool) -> None:
        if not info.context.user.is_superuser:
            msg = "Must be a superuser to be able add done tasks."
            raise GraphQLPermissionError(msg)

Now only superusers can add Tasks that are already done, since in this case the default value of Task.done is False, and Input permissions are only checked for non-default values.


Part 6: Adding Validation๐Ÿ”—

Mutations using MutationTypes can also be validated on both the MutationType and individual Input level.

To add validation for a MutationType, add the __validate__ classmethod to it.

from typing import Any

from undine import GQLInfo, MutationType
from undine.exceptions import GraphQLValidationError

from .models import Task


class TaskCreateMutation(MutationType[Task]):
    @classmethod
    def __validate__(cls, instance: Task, info: GQLInfo, input_data: dict[str, Any]) -> None:
        if input_data["done"]:
            msg = "Cannot create a done task."
            raise GraphQLValidationError(msg)

Now users cannot create tasks that are already marked as done. Boot up the Django server and make the following request:

1
2
3
4
5
mutation {
  createTask(input: {name: "New task", done: true}) {
    name
  }
}
You should see this response:
{
  "data": null,
  "errors": [
    {
      "message": "Cannot create a done task.",
      "locations": [
        {
          "line": 2,
          "column": 3
        }
      ],
      "path": [
        "createTask"
      ],
      "extensions": {
        "status_code": 400,
        "error_code": "VALIDATION_ERROR"
      }
    }
  ]
}

To add validation for an Input, define the input on the MutationType and decorate a method with @<input_name>.validate.

from undine import GQLInfo, Input, MutationType
from undine.exceptions import GraphQLValidationError

from .models import Task


class TaskCreateMutation(MutationType[Task]):
    name = Input()

    @name.validate
    def validate_name(self, info: GQLInfo, value: str) -> None:
        if len(value) < 3:
            msg = "Name must be at least 3 characters."
            raise GraphQLValidationError(msg)

Now users cannot create tasks with names that are less than 3 characters long.


Part 7: Adding Filtering๐Ÿ”—

Results from QueryTypes can be filtered using Filters defined in a FilterSet. To filter results, create a FilterSet for the Task model and add it to your TaskType.

from undine import Field, Filter, FilterSet, QueryType

from .models import Task


class TaskFilterSet(FilterSet[Task]):
    name_contains = Filter(lookup="icontains")
    done = Filter()


class TaskType(QueryType[Task], filterset=TaskFilterSet):
    pk = Field()
    name = Field()
    done = Field()
    created_at = Field()

Boot up the Django server and make the following request:

query {
  tasks(
    filter: {
      nameContains: "a"
    }
  ) {
    pk
    name
  }
}

Check the response. You should only see tasks with names that contain the letter "a".

Different Filters can also be combined to narrow down the results.

query {
  tasks(
    filter: {
      nameContains: "a"
      done: false
    }
  ) {
    pk
    name
  }
}

With this query, you should only see tasks that contain the letter "a" and are not done.

If you wanted to see either tasks containing the letter a or tasks that are not done, you could put the filters inside an OR block:

query {
  tasks(
    filter: {
      OR: {
        nameContains: "a"
        done: false
      }
    }
  ) {
    pk
    name
  }
}

Similar logical blocks exist for AND, NOT and XOR, and they can be nested as deeply as needed.


Part 8: Adding Ordering๐Ÿ”—

Results from QueryTypes can be ordered using Orders defined in an OrderSet. To order results, create an OrderSet for the Task model and add it to your TaskType.

from undine import Field, Order, OrderSet, QueryType

from .models import Task


class TaskOrderSet(OrderSet[Task]):
    pk = Order()
    name = Order()


class TaskType(QueryType[Task], orderset=TaskOrderSet):
    pk = Field()
    name = Field()
    done = Field()
    created_at = Field()

Adding an ordering enables you to order by that fields in both ascending and descending directions. Boot up the Django server and make the following request:

query {
  tasks(
    orderBy: [
      nameAsc
      pkDesc
    ]
  ) {
    pk
    name
  }
}

With this ordering, you should see the tasks ordered primarily by name in ascending order, and secondarily by primary key in descending order.


Next Steps๐Ÿ”—

In this tutorial, you've learned the basics of creating a GraphQL schema using Undine. It's likely your GraphQL schema has requirements outside of what has been covered here, so it's recommended to read the Queries, Mutations, Filtering, and Ordering sections next. The Pagination section is also helpful to learn how to paginate your QueryTypes using Relay Connections.

For more in-depth information on how Undine optimizes queries to your GraphQL Schema, as well as how to provide custom optimizations for more complex use cases, see the Optimizer section.