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,
}

You'll also need to fetch static files for GraphiQL, so run the following command:

python manage.py fetch_graphiql_static_for_undine

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, QueryType, RootType, create_schema

from .models import Task


class TaskType(QueryType[Task]): ...


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. QueryTypes automatically introspect their model to create Fields based on the model's fields — that's why you don't need to add anything to the TaskType class body to expose the model in this basic way.

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.

from undine import Entrypoint, QueryType, RootType, create_schema

from .models import Project, Step, Task


class ProjectType(QueryType[Project]): ...


class TaskType(QueryType[Task]): ...


class StepType(QueryType[Step]): ...


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


schema = create_schema(query=Query)

QueryTypes will automatically link to each other through their model's relations, so you don't need to do anything else here.

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, MutationType, QueryType, RootType, create_schema

from .models import Project, Step, Task


class ProjectType(QueryType[Project]): ...


class TaskType(QueryType[Task]): ...


class StepType(QueryType[Step]): ...


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


class TaskCreateMutation(MutationType[Task]): ...


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. You could also use the kind argument in the MutationType class definition to be more explicit.

1
2
3
4
5
6
from undine import MutationType

from .models import Task


class TaskCreateMutation(MutationType[Task], kind="create"): ...

Undine will automatically generate different Inputs for the MutationType based on what kind of MutationType is created:

  • For create mutations, the model's primary key is not included.
  • For update mutations, the primary key is required and all other fields are not required.
  • For delete mutations, only the primary key is included in both the input and output types.

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 queryable 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, Input, MutationType, QueryType, RootType, create_schema

from .models import Project, Step, Task


class ProjectType(QueryType[Project]): ...


class TaskType(QueryType[Task]): ...


class StepType(QueryType[Step]): ...


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


class TaskProjectInput(MutationType[Project], kind="related"): ...


class TaskCreateMutation(MutationType[Task]):
    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, Input, MutationType, QueryType, RootType, create_schema

from .models import Project, Step, Task


class ProjectType(QueryType[Project]): ...


class TaskType(QueryType[Task]): ...


class StepType(QueryType[Step]): ...


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


class RelatedProject(MutationType[Project]): ...


class TaskCreateMutation(MutationType[Task]):
    project = Input(RelatedProject)


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 Entrypoint, GQLInfo, QueryType, RootType, create_schema
from undine.exceptions import GraphQLPermissionError

from .models import Project, Step, Task


class ProjectType(QueryType[Project]): ...


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)


class StepType(QueryType[Step]): ...


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


# Mutations removed for brevity

schema = create_schema(query=Query)

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 Entrypoint, Field, GQLInfo, QueryType, RootType, create_schema
from undine.exceptions import GraphQLPermissionError

from .models import Project, Step, Task


class ProjectType(QueryType[Project]): ...


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)


class StepType(QueryType[Step]): ...


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


schema = create_schema(query=Query)

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 Entrypoint, GQLInfo, MutationType, QueryType, RootType, create_schema
from undine.exceptions import GraphQLPermissionError

from .models import Project, Step, Task


class ProjectType(QueryType[Project]): ...


class TaskType(QueryType[Task]): ...


class StepType(QueryType[Step]): ...


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


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)


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


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

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 Entrypoint, GQLInfo, Input, MutationType, QueryType, RootType, create_schema
from undine.exceptions import GraphQLPermissionError

from .models import Project, Step, Task


class ProjectType(QueryType[Project]): ...


class TaskType(QueryType[Task]): ...


class StepType(QueryType[Step]): ...


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


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)


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


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

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 Entrypoint, GQLInfo, MutationType, QueryType, RootType, create_schema
from undine.exceptions import GraphQLValidationError

from .models import Project, Step, Task


class ProjectType(QueryType[Project]): ...


class TaskType(QueryType[Task]): ...


class StepType(QueryType[Step]): ...


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


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)


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


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

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 Entrypoint, GQLInfo, Input, MutationType, QueryType, RootType, create_schema
from undine.exceptions import GraphQLValidationError

from .models import Project, Step, Task


class ProjectType(QueryType[Project]): ...


class TaskType(QueryType[Task]): ...


class StepType(QueryType[Step]): ...


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


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)


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


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

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 Entrypoint, FilterSet, QueryType, RootType, create_schema

from .models import Project, Step, Task


class ProjectType(QueryType[Project]): ...


class TaskFilterSet(FilterSet[Task]): ...


class TaskType(QueryType[Task], filterset=TaskFilterSet): ...


class StepType(QueryType[Step]): ...


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


schema = create_schema(query=Query)

Similar to QueryTypes, FilterSets automatically introspect their model to construct all possible filtering options depending on the model's fields and those fields' lookups. 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"
      doneExact: 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"
        doneExact: 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 Entrypoint, OrderSet, QueryType, RootType, create_schema

from .models import Project, Step, Task


class ProjectType(QueryType[Project]): ...


class TaskOrderSet(OrderSet[Task]): ...


class TaskType(QueryType[Task], orderset=TaskOrderSet): ...


class StepType(QueryType[Step]): ...


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


schema = create_schema(query=Query)

Similarly to QueryTypes and FilterSets, OrderSets automatically introspect their model to construct an Enum with all possible orderings (both in ascending and descending directions) based on the model's fields. 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.