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:
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.
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:
. .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
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
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.
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:
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:
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:
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 runpython manage.py startapp drinksto 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 registerdrinksinINSTALLED_APPSin a second.
Create our own migration
To turn that class in to a database table, we make a migration:
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:
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:
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:
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:
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:
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:
{
"id": 1,
"name": "grape soda",
"description": "very grapey"
}
That's what the serializer class is for. New file, drinks/serializers.py:
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:
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:
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:
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:
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 therest_frameworkimport 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.
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:
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:
{
"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:
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.
@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.
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:
{
"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:
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:
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:
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:
@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):
pip install requests
requests is a module that makes really simple requests to pages on the internet.
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.