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,
}
|
Now start the Django server and navigate to /graphql/ to see the GraphiQL UI.
Make the following request:
You should see this 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 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
from your 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, 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.
| >>> 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.
Your can also link the QueryTypes to each other by adding Fields for the Model related fields.
| 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 make an update mutation, and "delete" will make a delete mutation.
Create, update and delete mutations are executed differently
(see the Mutations section for more details).
You could also use the kind argument in the MutationType class definition to be more explicit.
| from undine import Input, MutationType
from .models import Task
class TaskCreateMutation(MutationType[Task], kind="create"):
name = Input()
done = Input()
|
The TaskCreateMutation MutationType will use the TaskType QueryType as the output type
since they share the same Model. In fact, all MutationTypes 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 mutation. 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, 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. First, 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:
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 Task instance returned by the QueryType.
For Field permissions, 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 using MutationTypes work similarly to query permissions using QueryTypes.
| 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 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:
| 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, 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. 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()
|
Now all Entrypoints created from this QueryType will have a filter argument that contains
the filtering options defined by the FilterSet.
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. 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()
|
Now all Entrypoints created from this QueryType will have an orderBy argument that contains
the ordering options defined by the OrderSet.
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.