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:
| 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:
| 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:
You should see the following response:
| {
"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.
| 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:
| 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.
| >>> 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:
| 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.
| 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:
| mutation {
createTask(input: {name: "New task"}) {
name
}
}
|
You should see this response:
| {
"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:
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:
| 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.