Django REST Framework - Build an API from Scratch

Timestamps

  • - Introduction
  • - Create a virtual environment
  • - Install packages
  • - Create our Django project
  • - Apply migrations
  • - Intro to admin site
  • - Create our model
  • - Create our own migration
  • - Start REST framework
  • - Create our serializer
  • - Create our view endpoints
  • - CRUD
  • - POST request
  • - Drink details - GET
  • - Drink details - PUT
  • - Drink details - DELETE
  • - Getting JSON through browser
  • - Consuming the API

An API allows multiple applications to communicate with one another. If you can describe all of your data in a consistent format, typically JSON, you can send and receive that data between totally different apps.

Stack Overflow has one. Instagram has one. Google has like a bajillion of them. And typically an API lets you view data, create new data, update data, and delete data, which is what we're going to build here.

This one starts from nothing. No prior project, no prior code, empty directory. It takes a little extra time at the beginning to get everything set up, but then we're all on the same page. I did this same thing with Flask in an earlier video, so this is the Django version of that. Django is a bit more involved, so if you want more background on the framework itself my Django crash course covers it, though it's not a prerequisite here.

The app is called drinks, a little API for getting information about your favorite beverages.

Virtual Environment

You need Python 3 for all of this. If you don't have it, grab it from python.org.

First off, a directory to work in:

Bash
mkdir drinks
cd drinks

Before installing anything, we want a virtual environment. That's where all the dependencies for this project live, so you don't get clashing versions across projects.

Bash
python3 -m venv .venv

That creates a .venv directory, and since it starts with a dot you'll need ls -a to see it.

At this point I open the whole drinks directory in VS Code (File > Open Folder), because then any terminal you open is already in the right path.

Now activate it:

Bash
. .venv/bin/activate

You'll see (.venv) show up on the left of your prompt. You go through that activation anytime you want to work on the project, including in every new terminal tab you open.

Install packages

Bash
pip install django
pip install djangorestframework

Django is the framework, Django REST framework is the tool we're going to use to actually build the REST API on top of it.

Once Django is installed you can just run django-admin by itself and it prints out every command available to you. runserver, startproject, all of it.

Create our Django project

Bash
django-admin startproject drinks .

The . at the end means put it in the current directory. Without it you'd get another folder wrapped around everything.

So now inside our outer drinks directory we have an inner drinks directory. Seeing the same name twice is a little strange, but the outer one can be named whatever you want, I only made it so the virtual environment and manage.py sit together.

Speaking of, startproject gives you a manage.py. Think of it as a wrapper around django-admin that does a little extra setup for you.

Bash
python manage.py runserver

That starts the server at http://127.0.0.1:8000. Open it and you get the Django welcome page. Don't get too excited, we have a lot more to do.

Apply migrations

The first thing you'll see is a complaint about 18 unapplied migrations. Migrations represent the data structure for your database, and they need applied before the tables actually exist.

I leave the server running and open a second terminal tab, activate the virtual environment there, then:

Bash
python manage.py migrate

A bunch of OKs scroll by and the essential tables are created.

Intro to admin site

Django has an admin page that lets you see your tables and the data inside them. It's handy when you're building an API because you can check your work against the database instead of guessing.

Go to http://127.0.0.1:8000/admin and you'll get a login, except there's no user yet. Make one:

Bash
python manage.py createsuperuser

It asks for a username, an email, and a password. I went with admin and a really lame password, and it complains about security, which you can override by typing y.

Log in and you'll see the tables Django made for you. Now we want our own table in there.

Create our model

Whatever your API is about, you need a model that represents it. In our case, what does a drink look like? The model is just a Python class with some attributes, and as your app grows you get more and more models and the database gets more complex.

Inside the inner drinks directory, make a new file models.py:

Python
from django.db import models


class Drink(models.Model):
    name = models.CharField(max_length=200)
    description = models.CharField(max_length=500)

Two things tell Django this is a model: it's in models.py, and it inherits from models.Model.

The attributes get a data type off of models, and there are a bunch of them. CharField is the one we want, and it takes a max_length. I don't know what the world's longest drink name is, but I highly doubt there's much out there over 200 characters.

We're not creating a specific drink here, we're describing what every drink should look like.

💡 Tip[!tip]
Normally you'd run python manage.py startapp drinks to get an app with all of these files scaffolded for you. Here I'm just adding the files to the project package by hand, which works fine, it's just why we have to register drinks in INSTALLED_APPS in a second.

Create our own migration

To turn that class in to a database table, we make a migration:

Bash
python manage.py makemigrations drinks

Run it right now and you get an error:

No installed app with label 'drinks'.

There's a settings.py file in the project, and in it a list of all the apps you're using. Ours isn't on it. Add it:

Python
INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'drinks',
]

Try again and it creates drinks/migrations/0001_initial.py.

The migration describes the change to our data structure, but it doesn't apply it. For that we run the same command as before:

Bash
python manage.py migrate

You'll see it applying our migration this time.

Registering the model in admin

One more thing to get the table showing up in the admin panel. Make an admin.py inside drinks:

Python
from django.contrib import admin

from .models import Drink

admin.site.register(Drink)

The dot in .models refers to the same directory.

This requires a restart of the server, so kill it with Ctrl-C and run python manage.py runserver again. Refresh the admin page and there's our drinks table.

You don't have to use the admin site at all. If you don't think it's helpful you can skip the admin.py and the super user entirely. For my sake I think it's worth it.

Add a couple of rows through the admin so we have something to read: grape soda / very grapey, and orange soda / very orangey.

They show up as "Drink object (1)" and "Drink object (2)", which is not super useful. You can change how an object gets represented by adding a __str__ method to the model:

Python
from django.db import models


class Drink(models.Model):
    name = models.CharField(max_length=200)
    description = models.CharField(max_length=500)

    def __str__(self):
        return self.name + ' ' + self.description

self is how you create a method on the drink object, and all this has to do is return a string. Refresh and you get a bit more detail.

Start REST framework

We have a model and some data in the database. Now let's get it out through an API.

We already installed Django REST framework but it needs added to the installed apps too:

Python
INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'drinks',
    'rest_framework',
]

Create our serializer

A serializer describes the process of going from a Python object to JSON. The structure we want back looks like this:

JSON
{
    "id": 1,
    "name": "grape soda",
    "description": "very grapey"
}

That's what the serializer class is for. New file, drinks/serializers.py:

Python
from rest_framework import serializers

from .models import Drink


class DrinkSerializer(serializers.ModelSerializer):
    class Meta:
        model = Drink
        fields = ['id', 'name', 'description']

ModelSerializer, such a difficult word to say.

The inner Meta class is the metadata describing the model. model is the class we're serializing, and fields is the list of what gets included. id is added to the model automatically by Django, so we get it for free.

More on what else you can do in here: Serializers - Django REST framework.

Create our view endpoints

We have a model, we have a serializer, now we need an endpoint. An endpoint being a certain URL you can access data from.

New file, drinks/views.py:

Python
from django.http import JsonResponse

from .models import Drink
from .serializers import DrinkSerializer


def drink_list(request):
    drinks = Drink.objects.all()
    serializer = DrinkSerializer(drinks, many=True)
    return JsonResponse(serializer.data, safe=False)

Three steps in that function. Get all the drinks, serialize them, return JSON.

Drink.objects.all() gets everything. DrinkSerializer(drinks, many=True) handles a list instead of a single object, which is what many is for. Then JsonResponse sends serializer.data back.

The safe=False is there because if you leave it off you get this:

In order to allow non-dict objects to be serialized set the safe parameter to False.

Now we need to say what URL hits this view, and that's urls.py in the project directory:

Python
from django.contrib import admin
from django.urls import path

from drinks import views

urlpatterns = [
    path('admin/', admin.site.urls),
    path('drinks/', views.drink_list),
]

Visit http://127.0.0.1:8000/drinks/ and there's our list of data.

If you'd rather have an object than a bare list, throw the serializer data in a dictionary:

Python
return JsonResponse({'drinks': serializer.data})

Now you get an object with a drinks attribute holding the list, and since it's a dictionary again you don't need safe=False anymore. That's generally how I prefer to return data, but either one works.

Congratulations, at this point you have a working API. It's pretty limited, all you can do is get a list, but it works.

CRUD

This whole process is called CRUD: create, read, update, delete. Right now we have the read. We still need the C, the U, and the D.

Before we build the other endpoints, a quick word for anybody who's new. That function takes a GET request. When you literally just put the URL in your browser, you're getting data, that's why it's called GET. Don't overthink it.

You can see it yourself if you open Inspect and go to the Network tab, then refresh. The request method is GET, there's the URL, there's the response.

The other methods we need are POST, PUT, and DELETE. And a single function can take more than one of them. To do that we use a decorator, which is something you put above a function to describe its functionality in some way:

Python
from rest_framework.decorators import api_view


@api_view(['GET', 'POST'])
def drink_list(request):
    if request.method == 'GET':
        ...

Note the methods go in as a list, one argument, not several.

Don't feel like you have to have all of this memorized. I was referring to the Django REST framework docs the whole time I built this, mostly to get the imports right.

💡 Tip[!tip]
If VS Code says the rest_framework import cannot be resolved even though you installed it, it's probably the interpreter. Cmd-Shift-P (Ctrl-Shift-P on Windows), type "Select Interpreter", and pick your virtual environment. The dependencies are all inside .venv, so you have to point the editor at it instead of your global install. It's just the lens VS Code uses to see what you have installed, things would likely run fine either way.

POST request

Adding a drink is the same process as reading one, just in the opposite order. We take the data they sent us, deserialize it, and create a drink out of it.

Python
from rest_framework import status
from rest_framework.response import Response


@api_view(['GET', 'POST'])
def drink_list(request):
    if request.method == 'GET':
        drinks = Drink.objects.all()
        serializer = DrinkSerializer(drinks, many=True)
        return JsonResponse({'drinks': serializer.data})

    if request.method == 'POST':
        serializer = DrinkSerializer(data=request.data)
        if serializer.is_valid():
            serializer.save()
            return Response(serializer.data, status=status.HTTP_201_CREATED)

Instead of passing in objects we already have, the data comes off the request with data=request.data. Then serializer.is_valid() checks what they sent, serializer.save() writes it, and we hand back the data with a 201.

Watch out for the autocomplete on those imports, the light bulb suggests a few and none of them were the ones I wanted. Write them out yourself:

Python
from rest_framework.response import Response
from rest_framework import status

Now test it. I use Postman for this. Set the method to POST, go to the body, pick raw, change the type to JSON, and send:

JSON
{
    "name": "strawberry soda",
    "description": "very good"
}

You don't send an id, that gets done automatically.

If you get a runtime error complaining about the slash in the URL, check urls.py against what you typed. Our path is drinks/ with the trailing slash and the URL I sent to was missing it. Add the slash and send again, and it comes back with an id on it, because we hand the data back in the response after it's been saved.

Two ways to confirm it landed. Get the drinks again and you'll see three of them, or open the admin page and look at the table.

Drink details - GET

Getting details on one item is a pretty important part of an API. You go to /drinks/1 and get just that drink back.

First a new path in urls.py. Same drinks prefix, but with a parameter of type int:

Python
urlpatterns = [
    path('admin/', admin.site.urls),
    path('drinks/', views.drink_list),
    path('drinks/<int:id>', views.drink_detail),
]

Whatever value is in that spot in the URL gets passed to the view as the id argument.

Now the view. We're going to do it a little differently than the list, because there's a better way. Up in drink_list we used JsonResponse, but Response comes from Django REST framework and it's the preferred way to do things, since it can return JSON or HTML depending on who's asking.

Python
@api_view(['GET', 'PUT', 'DELETE'])
def drink_detail(request, id):
    try:
        drink = Drink.objects.get(pk=id)
    except Drink.DoesNotExist:
        return Response(status=status.HTTP_404_NOT_FOUND)

    if request.method == 'GET':
        serializer = DrinkSerializer(drink)
        return Response(serializer.data)

Drink.objects.get(pk=id) looks it up by primary key, and it throws if there's nothing there, so we wrap it in a try and catch Drink.DoesNotExist. Everything in status is spelled out for you, HTTP_404_NOT_FOUND being the one we want here.

Two lines is all the GET takes. Try it with 1, 2, 3, then try 4 and you get the 404.

Do the same thing in the browser and you get the browsable HTML view, which is kind of cool. It's similar to the admin panel except it's on the front end, it doesn't require a login, and you can't see everything in the database through it.

Drink details - PUT

PUT is for updating, and the process is close to the POST we already wrote.

Python
    elif request.method == 'PUT':
        serializer = DrinkSerializer(drink, data=request.data)
        if serializer.is_valid():
            serializer.save()
            return Response(serializer.data)
        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

The difference from POST is that first argument. We pass in the drink we already looked up, so the serializer knows it's editing that row rather than making a new one, plus the incoming data.

Test it in Postman by getting drink 3, then switching to PUT and changing the body:

JSON
{
    "name": "soda edited",
    "description": "very good"
}

Send it and you get the new data back. Get it again and the change has persisted.

Drink details - DELETE

Delete is super easy. We already have a reference to the drink object:

Python
    elif request.method == 'DELETE':
        drink.delete()
        return Response(status=status.HTTP_204_NO_CONTENT)

drink.delete() is just the typical way of doing it in Django. Then a 204, which means it worked and there's nothing to send back.

In Postman, set the method to DELETE, use the id of 3, and clear out the body since we don't need one. You get nothing back, and when you try to GET that drink it's gone.

Switching the list over to Response

Now that we've seen how much nicer Response is, let's take that behavior back up to drink_list:

Python
    if request.method == 'GET':
        drinks = Drink.objects.all()
        serializer = DrinkSerializer(drinks, many=True)
        return Response(serializer.data)

Still works in Postman, and now the browser gives us the HTML view for the whole list too. You can even edit data right from that page. Go to /drinks/1, put an object in the box, hit PUT, and it updates.

So we've now got three different ways of working with this data. The admin panel, which doesn't actually use the API, and then for the API itself the browsable HTML page or a tool like Postman.

Getting JSON through browser

You might just want plain JSON in the browser instead of that HTML view. Stick .json on the end of the URL and it doesn't work yet.

The fix is building that capability in to our URLs, so we don't just serve those paths but those paths with different extensions:

Python
from rest_framework.urlpatterns import format_suffix_patterns

urlpatterns = [
    path('admin/', admin.site.urls),
    path('drinks/', views.drink_list),
    path('drinks/<int:id>', views.drink_detail),
]

urlpatterns = format_suffix_patterns(urlpatterns)

Whatever format_suffix_patterns returns replaces the urlpatterns variable.

Reload and you get an unexpected keyword argument format. The quick fix is a parameter on the views:

Python
@api_view(['GET', 'POST'])
def drink_list(request, format=None):
    ...


@api_view(['GET', 'PUT', 'DELETE'])
def drink_detail(request, id, format=None):
    ...

Now http://127.0.0.1:8000/drinks/1.json gives you the JSON, same as you'd get from Postman, and dropping the .json puts you back in the HTML view. The full rules for this are in Format suffixes - Django REST framework.

Consuming the API

I don't want to build a whole full stack app in this video, but I do want to show what consuming the API looks like, so we'll do a very simple request through Python and leave the rest open for future research.

Leave the server running, because we still need it. Then in a new file in the main directory, consume.py.

In a new terminal tab (with the virtual environment activated):

Bash
pip install requests

requests is a module that makes really simple requests to pages on the internet.

Python
import requests

response = requests.get('http://127.0.0.1:8000/drinks/')
print(response.json())

Run it with python consume.py.

If you get a No connection adapters were found error, you left off the protocol, it needs the http:// on the front.

And that's it, we get our data in a Python application. Literally three lines of code.

We did a ton in this one and it's a lot to learn. I had to reference the documentation constantly while building it, especially for all the different imports, so don't feel like you have to be perfect from day one. Maybe by day two or three, but until then you're okay to make a few mistakes.