How to build a RESTful API with Django

Michael Prather
10 min readMay 28, 2021

--

I was recently challenged to create an app using Django despite having never written a line of Python code before. In this tutorial, I’ll share what I learned by showing how to build a RESTful API service for simple todo list app using Django.

If, like me, you’ve never used Django, I found the LinkedIn Learning course Learning Django to be a helpful introduction. Since I’ll be using Django to create a RESTful API service, this LinkedIn Learning course on Building RESTful APIs with Django may also be informative.

I’m using VSCode on a Mac to build the project. Throughout the tutorial, I’ll be sharing shell commands. Please note that if you’re using a PC, these commands will be slightly different.

Creating the Django project

I start by downloading and installing Python. I also install Pip, Python’s package manager. With Python and Pip installed, I create and activate a virtual environment for the new project using venv using this shell command:

$ python3 -m venv .venv
$ source .venv/bin/activate

The first command creates the virtual environment in a project subfolder called .venv. The second command activates the new virtual environment.

With the virtual environment activated, I next install Django using this command:

$ python -m pip install Django

To verify that Django was installed, I enter this command which returns the version of Django that is installed:

$ python -m django --version

With Django installed to our virtual environment, I use this command to create the Django project:

$ django-admin startproject server

You’ll notice that I’ve chosen to name the Django project server, but you can generally name your Django project whatever you like.

Next, I apply migrations:

$ cd server
$
python manage.py migrate

To verify that the Django project is functional, I start the development server:

$ python manage.py rumserver

With the development server started, I open the Django project in a browser at http://127.0.0.1:8000 to confirm a successful installation.

Django development environment

Configuring the Django REST Framework

I’m using Django REST framework for developing the API. I install it using this shell command:

$ pip install djangorestframework

Next I add rest_framework to Django’s INSTALLED_APPS setting. If you’ve followed my project naming and directory conventions, you’ll find this setting in {PROJECT}/server/server/settings.py. I modify only the value of INSTALLED_APPS as follows:

INSTALLED_APPS = [
...
'rest_framework',
]

Creating the Django todo app

I’m now ready to create the todo app in Django. I create the app using this command:

$ python manage.py startapp todo

Before I can begin using the new app, I must install it by adding todo to Django’s INSTALLED_APPS setting in the settings.py file as follows:

INSTALLED_APPS = [
...
'todo',
]

Building the Model layer

Django apps follow a Model-View-Controller design pattern. For the todo app, I’ll start by building the model layer. This layer provides structure to the data. Django automatically structures the database using these models. For simplicity, I’ve chosen to use SQLite as the database. To create the model, replace the contents of .../server/todo/models.py with the following:

from django.db import modelsclass ToDo(models.Model):
task = models.CharField(max_length=2048)
completed = models.BooleanField(default=False)
def __str__(self):
return self.task

This code creates a ToDo model class with two properties: task and completed. The task field is a character field with a maximum length of 2,048 characters. The completed field is a boolean with a default value of False. A third, unique, auto-incremented primary key named id will also be automatically created by Django. The __str__ method tells Django to represent each todo by it’s task when the todo is converted to a string.

Since I’m using Django as a RESTful API service, the model must be serialized. To do this, I create a file in the todo app folder called serializers.py containing this code:

from rest_framework import serializers
from todo.models import ToDo
class ToDoSerializer(serializers.ModelSerializer):
class Meta:
model = ToDo
fields = ('id', 'task', 'completed')

Finally, I must apply the todo migrations using this command:

$ python manage.py makemigrations todo
$ python manage.py migrate

To test if it works, I’ll try adding and querying data in the todo app database table using shell:

$ python manage.py shell>>> from todo.models import ToDo# Create a new todo>>> todo = ToDo(task="Exercise")
>>> todo.save()
# Now it has an ID
>>> todo.id
1
# The new todo has not been completed
>>> todo.completed
False
# Show all todos (there should just be one)
>>> ToDo.objects.all()
<QuerySet [<ToDo: Exercise>]>

Creating the View Layer

With our Model layer complete, I’ll now update the View layer. Since Django is only serving as RESTful API service, its View layer will receive and return data but not render HTML. I’ll update the View by replacing the contents of the views.py file in the todo app with the following:

from django.http.response import JsonResponse
from rest_framework import status
from rest_framework.exceptions import NotFound
from rest_framework.parsers import JSONParser
from rest_framework.decorators import api_view
from todo.serializers import ToDoSerializer
from todo.models import ToDo
@api_view(['GET', 'POST', 'DELETE'])
def todos(request):
# get all todos
if request.method == 'GET':
todos = ToDo.objects.all()
serializer = ToDoSerializer(todos, many=True)
return JsonResponse(serializer.data, safe=False)
# create todo
elif request.method == 'POST':
request_data = JSONParser().parse(request)
serializer = ToDoSerializer(data=request_data)
if serializer.is_valid():
serializer.save()
return JsonResponse(serializer.data, status=status.HTTP_201_CREATED)
return JsonResponse(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
# purge all completed todos
elif request.method == 'DELETE':
completedToDos = ToDo.objects.filter(completed=True)
if completedToDos.count() > 0:
completedToDos.delete()
return JsonResponse({}, status=status.HTTP_204_NO_CONTENT)
@api_view(['PATCH', 'DELETE'])
def todo(request, pk):
try:
todo = ToDo.objects.get(pk=pk)
except ToDo.DoesNotExist:
raise NotFound()
# mark todo as completed
if request.method == 'PATCH':
serializer = ToDoSerializer(todo, {'completed': True}, partial=True)
if serializer.is_valid():
serializer.save()
return JsonResponse({}, status=status.HTTP_204_NO_CONTENT)
return JsonResponse(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
# delete todo
elif request.method == 'DELETE':
todo.delete()
return JsonResponse({}, status=status.HTTP_204_NO_CONTENT)

The first function receives and responds to three types of RESTful requests:

  1. GET to retrieve a list of todos. The response’s payload includes an array of todo objects (without record limits).
  2. POST to create a todo. This request fails if the request payload does not contain a valid task property value. The response includes the created todo (including the generated id) in its payload.
  3. DELETE to delete all completed todos. This request succeeds even if there are no completed tasks to be deleted.

The second function similarly receives and responds to two types of RESTful requests:

  1. PATCH to mark a todo as completed. This request automatically sets the completed value to True, so it neither requires nor returns a payload.
  2. DELETE to delete a todo.

If either of these two requests contains an invalid id, the request fails.

Creating the Controller layer

I’ve created our Model and View layers, but still need a way for API requests to be made. I’ll handle this by creating a simple Controller layer that will establish endpoints and route requests to the related View. In the todo app folder, create a file called urls.py with the following contents:

from django.urls import pathfrom . import viewsurlpatterns = [
path('', views.todos),
path('<int:pk>/', views.todo),
]

This tells Django to route any requests made to v1/todo to todos function in our views.py file. Requests to v1/todo/{id} will instead be routed to the todo function.

Before the endpoints can be accessed, I must include them in the urls.py file in the server folder. I do this by replacing the contents of that file with the following:

from django.contrib import admin
from django.urls import path
from django.urls.conf import include
from todo import urlsurlpatterns = [
path('api/v1/todo', include(urls)),
path('admin/', admin.site.urls),
]

Testing the Todo app

I’ve finished modeling the data in the Model layer, created a View layer for responding to requests, and created a Controller layer for routing requests. Let’s write some tests to see if it works. To start, I’ll replace the contents of the tests.py file in the todo folder with the following:

from rest_framework.test import APITestCase
from rest_framework.utils import json
from todo.models import ToDoclass ToDoCreateTestCase(APITestCase):
api_path = '/api/v1/todo/'
request_payload = {
"task": "Run a test"
}
def create_todo(self):
response = self.client.post(self.api_path, self.request_payload, format='json')
response_payload = json.loads(response.content)
status_code = response.status_code
return status_code, response_payload
def test_create_todo(self):
initial_todo_count = ToDo.objects.count()
status_code, response_payload = self.create_todo()
# request succeeds
self.assertEqual(status_code, 201)

# record was created
self.assertEqual(
ToDo.objects.count(),
initial_todo_count + 1,
)
# task was not changed
for attr, expected_value in self.request_payload.items():
self.assertEqual(response_payload[attr], expected_value)
# id exists in response payload
self.assertGreater(response_payload['id'], 0)

def test_delete_one_todo(self):
initial_todo_count = ToDo.objects.count()
# create a todo to delete
_, response_payload = self.create_todo()
response = self.client.delete(
self.api_path + str(response_payload['id']) + '/'
)

# request succeeds
self.assertEqual(response.status_code, 204)
# record was deleted
self.assertEqual(ToDo.objects.count(), max(initial_todo_count - 1, 0))
def test_patch_todo(self):
# fails if record does not exist
response = self.client.patch(self.api_path + '0' + '/')
self.assertEqual(response.status_code, 404)
# create a todo to update
_, response_payload = self.create_todo()
id = response_payload['id']
response = self.client.patch(self.api_path + str(id) + '/')# request succeeds
self.assertEqual(response.status_code, 204)
# todo was marked as completed
todo = ToDo.objects.get(pk=id)
self.assertTrue(todo.completed)
def test_delete_completed(self):
# create a list of 5 todos and mark last 3 as completed
ids = []
i = 0
while i < 5:
_, response_payload = self.create_todo()
id = response_payload['id']
ids.append(id)
if i > 1:
self.client.patch(self.api_path + str(id) + '/')
i += 1
self.assertEqual(ToDo.objects.all().count(), 5)response = self.client.delete(self.api_path)# request succeeds
self.assertEqual(response.status_code, 204)
# exactly three todos were deleted
self.assertEqual(ToDo.objects.all().count(), 2)
def test_get(self):
# create a list of 3 todos
todos_count = 3
i = 0
while i < todos_count:
self.create_todo()
i += 1
response = self.client.get(self.api_path)
response_payload = json.loads(response.content)
# request succeeds
self.assertEqual(response.status_code, 200)
# exactly three todos were returned
self.assertEqual(len(response_payload), todos_count)

This code mocks requests to the Views to test that the requests are properly parsed and the responses returned are as expected. I can run the tests using this shell command:

$ python manage.py test

Successful tests should return this response:

Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.....
----------------------------------------------------------------------
Ran 5 tests in 0.055s
OK
Destroying test database for alias 'default'...

Since these tests exclude the Controller layer, I’ll also use Postman for end-to-end testing. Note: make sure your development server is running before sending a Postman request. Here’s a screen shot of Postman following a successful request which returns the todo we created via the command line earlier:

End-to-end testing of Django RESTful API using Postman

Enable CORS

To limit the client apps that can make API requests, I need to enable CORS. I’ll make use of the django-cors-headers package to accomplish this. First, I install the package using the command:

$ pip install django-cors-headers

Then, I add it to the INSTALLED_APPS list in the settings.py file.

INSTALLED_APPS = [
...
'corsheaders',
...
]

Finally, I add a middleware class to the MIDDLEWARE property in the settings.py file.

MIDDLEWARE = [
...
'corsheaders.middleware.CorsMiddleware',
'django.middleware.common.CommonMiddleware',
...
]

Note: the order in which middleware classes are included matters. Make sure you include the CorsMiddleware class before Django’s CommonMiddleware class.

Even though I’m building just a simple todo app, I should limit the origins that can make API requests when the app is in a production environment. I’ll utilize environment variables to determine if the app is in production and if so, which origins and hosts are allowed.

First, I’ll install the Django Environ package to assist with loading the environment variables. I install it using this command:

$ pip install django-environ

Next, I modify the settings.py file to import the newly installed package and read the environment variable value and set DEBUG, ALLOWED_HOSTS, and ALLOWED_ORIGINS values accordingly.

# insert after the following line:
# from pathlib import Path
from import environ
env = environ.Env(DEBUG=(bool, FALSE))
environ.Env.read_env()
PRODUCTION_ENV = env.get_value('PRODUCTION_ENV', bool, False)

Since environment variables values are always strings, I cast the value to boolean and set a default value of False in case the environment variable is not found.

In the same settings.py file, I change the value of DEBUG to equal PRODUCTION_ENV != True. This will turn off DEBUG mode for security purposes when the app is in production. I also replace the statement declaring ALLOWED_HOSTS with the following conditional statement:

if (PRODUCTION_ENV == True):
ALLOWED_HOSTS = env.list('ALLOWED_HOSTS')
CORS_ALLOWED_ORIGINS = env.list('ALLOWED_CORS_ORIGINS')
else:
ALLOWED_HOSTS = ['*']
CORS_ALLOW_ALL_ORIGINS = True

Finally, I create a .env file in the {PROJECT}/server/server folder with the following contents:

PRODUCTION_ENV=True
ALLOWED_HOSTS=localhost,127.0.0.1,[::1]
ALLOWED_CORS_ORIGINS=http://localhost:8000

Now, when the app is in production, it will only allow hosts and origins as defined in the ALLOWED_HOSTS and ALLOWED_CORS_ORIGINS environment variables respectively. When the app is not in production, all hosts and origins will be accepted. The environment variable values above are for use locally. With the development server running, I can quickly test that the settings work by opening a browser to http://127.0.0.1:8000/api/v1/todo/.

A successful GET request can still be made from a browser, even with CORS settings applied.

Note: using comma-separated values, you can define multiple hosts and/or origins in the environment variable(s) (ex. ALLOWED_HOSTS=foo.com,bar.com). The above code splits the string value using the comma as a delimiter.

Deployment

My project needs a way of communicating to the host which packages it requires and therefore must be installed during deployment. I can do this by creating a requirements.txt using this shell command:

$ pip freeze > requirements.txt

For simplicity, I chose to use a SQLite database. Deploying with SQLite can be problematic. Fortunately, Django is also compatible with several other databases.

Finally, Django automatically tries to collect static files like images. Since our project is simply a RESTful API service, we never created instructions for collecting these static files since our project does not require them. Consequently, deployment to some hosts may fail. Some hosts like Heroku have settings to disallow the collection of static files to be disabled. Alternatively, I declared aSTATIC_ROOT in the settings.py as follows:

STATIC_ROOT = BASE_DIR / 'static'

Since these files are collected during deployment, I excluded this folder from version control by adding this reference to the project’s .gitignore file:

...
static/
...

Final Thoughts

You can view the source code for building a Django RESTful API on GitHub. I hope you found this tutorial informative. Please take a moment to share your thoughts and questions below.

--

--

Michael Prather
Michael Prather

Written by Michael Prather

Sr. Software Engineer at Nomic Networks

No responses yet