Create a REST API with Python and Django
Introduction
We’ll follow this tutorial to understand what do we need to deploy a CRUD Rest API written in Python and Django, as well as using Postgres as the relational database and Docker for deployment.
According to the Django official website:
Django is a high-level Python web framework that encourages rapid development and clean, pragmatic design. Built by experienced developers, it takes care of much of the hassle of web development, so you can focus on writing your app without needing to reinvent the wheel. It’s free and open source.
Requirements that should be already installed in our server for this project:
- Python 3
- Docker Engine and Docker Compose
- Django package
- django-admin CLI (command in the next section)
- (Optional) Some API testing tool, such as Postman
- (Optional) Some Database testing tool, such as Tableplus
Getting started
In order to ensure robustness and congruency in our code, it’s always recommended to create a virtual environment exclusive for the new project. You can do it by either:
- Using
venv
from command line - Creating the project from your IDE and letting it create the virtual environment for you
- Using a Python Docker container with all the required packages installed there
- Etc…
Install Django
Also install the django-admin-cli
package to initialize the Django project
As Django is mainly focused on web development, it doesn’t include a Rest framework by default, so it must be installed as a python dependency:
Initialize the Django project
Launch the following commands from the CLI:
Django project initialization
Configure the project
settings.py
Now go to <djangoprojectname>/settings.py
and modify it as follows:
- Add
import os
at the top of the file - Add
'<djangoapp>'
and'rest_framework'
to theINSTALLED_APPS
list - Set the environment variables to configure the database (Postgres):
settings.py - Postgres configuration
DATABASES = {
'default': {
'ENGINE': os.environ.get('DB_DRIVER','django.db.backends.postgresql'),
'USER': os.environ.get('PG_USER','postgres'),
'PASSWORD':os.environ.get('PG_PASSWORD','postgres'),
'NAME': os.environ.get('PG_DB','postgres'),
'PORT': os.environ.get('PG_PORT','5432'),
'HOST': os.environ.get('PG_HOST','localhost'), # uses the container if set, otherwise it runs locally
}
}
Configure the Django App
models.py
The models are the representation of our objects in the Database realm, i.e. the ORM.
Go to <djangoapp>/models.py
and replace its content with the following:
models.py
In this example:
User
is the name of the modelname
andemail
are fields of theUser
model
serializers.py
A serializer is a class that converts data from the database to JSON and vice versa.
According to Django REST framework documentation:
Serializers allow complex data such as querysets and model instances to be converted to native Python datatypes that can then be easily rendered into
JSON
,XML
or other content types. Serializers also provide deserialization, allowing parsed data to be converted back into complex types, after first validating the incoming data.
Create a new file <djangoapp>/serializers.py
and add the following code:
serializer.py
Where:
UserSerializer
is the name of the serializerserializers.ModelSerializer
is the base class of the serializerMeta
is a class that contains the metadata of the serializermodel
is the model that contains the metadata of the serializerfields
is the list of fields the serializer will use. In this case, we use__all__
to use all the fields of the model
views.py
Views are the endpoints of our Rest application, where we define the URLs of the HTTP requests (GET
, POST
, PUT
, etc…).
In Java Spring Boot, views
would be each of the @Controller
methods that reply to an HTTP request.
Modify the <djangoapp>/views.py
and add the following code:
views.py
from django.shortcuts import render
from rest_framework.response import Response
from rest_framework.decorators import api_view
from .models import User
from .serializers import UserSerializer
# Create your views here.
@api_view(['GET'])
def getData(request):
users = User.objects.all()
serializer = UserSerializer(users, many=True)
return Response(serializer.data)
@api_view(['GET'])
def getUser(request, pk):
users = User.objects.get(id=pk)
serializer = UserSerializer(users, many=False)
return Response(serializer.data)
@api_view(['POST'])
def addUser(request):
serializer = UserSerializer(data=request.data)
if serializer.is_valid():
serializer.save()
return Response(serializer.data)
@api_view(['PUT'])
def updateUser(request, pk):
user = User.objects.get(id=pk)
serializer = UserSerializer(instance=user, data=request.data)
if serializer.is_valid():
serializer.save()
return Response(serializer.data)
@api_view(['DELETE'])
def deleteUser(request, pk):
user = User.objects.get(id=pk)
user.delete()
return Response('User successfully deleted!')
Where:
@api_view
is a decorator that will convert the function into a view (same as@annotations
in Java)getData
: return all the users in the database (GET
)getUser
: return a single user from the database (GET
)addUser
: add a user to the database (POST
)updateUser
: update a user in the database (PUT
)deleteUser
: delete a user from the database (DELETE
)
We use the User
model to retrieve the data from the Database and manipulate them as objects, and the UserSerializer
to convert that data to JSON (serializer.data
attribute).
Routes
Routes are the URL used to access the API. Each route is linked to a view
, so when accessing a URL the app knows what function to call.
In Java Spring Boot the routing is the value
defined for each @RequestMapping
method (@GetMapping(value="/api/path/to/operation")
, @PostMapping(value="/api/another/path")
, etc…)
Modify the file <djangoapp>/urls.py
to add the required routes:
urls.py
Where:
urlpatterns
is the array that includes all the available URLs or patternspath
is the function that creates the routes (mappings between URLs and views)- The created routes are:
<empty>
: this will access thegetData
view and return all the users in the DBcreate
: this will access theaddUser
view and create a new user from the request body dataread/<str:pk>
: this will return a singleUser
that matches the providedpk
IDupdate/<str:pk>
: this will update theUser
with ID equal topk
with the info from the request body datadelete/<str:pk>
: this will delete theUser
with ID equal topk
if it exists in the DB
- The
<str:pk>
syntax defines the parameters sent along with the URL, being the first part the data type and the second one the name of the expected variable by the view function (ex: in any of theread/update/delete
routes, the view function expects an input parameter namedpk
)
The <datatype:variable_name>
syntax is the same as the @PathVariable
annotation in Spring Boot.
Update the Django project
<djangoproject>/urls.py
Now we need to add the app routes to the project.
Open the <djangoproject>/urls.py
and replace the content with the following (or just add the missing parts):
<djangoproject>/urls.py
"""
URL configuration for djangorestapi project.
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/4.2/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path('admin/', admin.site.urls),
# Here replace 'djangorestapp' with the name of your django application
path('users/', include('djangorestapp.urls'))
]
Where:
path('admin/', admin.site.urls)
adds the routes of the admin panel to the django projectpath('users/', include('<djangoapp>.urls'))
adds the routes of the<djangoapp>
to thedjangoproject
Here, each path
is equivalent to the @RequestMapping(value="path")
for a @Controller
class (do not mistake it with the @RequestMapping
of each Controller method!).
Create a bash
script to launch the application
There are several steps involved in the Django server initialization, so it’s easier to keep them in a script. What we need to do is:
- Create migration for the
djangoapp
- Run the
djangoapp
migrations - Launch the server
Create a django.sh
file in the root of the project and add the following code (remember to change the name of the django app with yours!!):
django.sh
#!/bin/bash
echo "Creating Migrations..."
python manage.py makemigrations djangorestapp
echo "========================================"
echo "Starting Migrations..."
python manage.py migrate
echo "========================================"
echo "Starting Server..."
python manage.py runserver 0.0.0.0:8000
We still can’t test the project because it requires a Postgres database available in localhost:5432
as specified in the settings.py
project file. We’ll use docker to provide that.
Keep track of the project requirements
Create a requirements.txt
file at the root of the project and include these dependencies as well as some more that we’ll need later:
IDEs usually have an option to auto-generate it. IntelliJ PyCharm has it in Tools --> Sync Python Requirements
Dockerize the project
Now we’ll containerize our Django project and prepare a Docker Compose file to easily deploy the app as well as the database
Dockerfile
The Dockerfile builds a Docker image that contains our application running on a lightweight linux operating system with the required Python version already included.
Create a Dockerfile
file in the root directory of the project and add the following lines:
Dockerfile
FROM python:3.9.0-buster
# Set unbuffered output for python
ENV PYTHONUNBUFFERED 1
# Create app directory
WORKDIR /app
# Install app dependencies
COPY requirements.txt .
RUN pip install -r requirements.txt
# Bundle app source
COPY . .
# Expose port
EXPOSE 8000
# Entrypoint to run the django.sh file
ENTRYPOINT ["/app/django.sh"]
Where:
FROM python:3.9.0-buster
: select a Debian Buster image with Python 3.9.0 installed as the base image. Make sure you select a base image with the same Python version as your projectENV PYTHONUNBUFFERED 1
: this makes python output unbuffered, meaning the output will be sent directly to the terminal without being stored in a buffer
The rest of the commands are self-explanatory with its proper comment.
Docker Compose
Now it’s time to create the docker-compose.yml
file to deploy our application. Things to take into account:
- We’ll define it so our App Docker image is built from the source path instead of fetched from a registry. This should be changed when deploying, as we should be relying on a secure and trusted registry where our images are continuously updated (CI/CD, you know…)
- Postgres environment variables should be replaced and secured outside the
docker-compose.yml
file, but for learning purposes we’ll leave them like this
Copy the following configuration into a docker-compose.yml
within the project root directory:
docker-compose.yml
version: "3.9"
services:
djangorestapp:
container_name: djangorestapp
build: .
ports:
- "8000:8000"
environment:
- PG_USER=postgres
- PG_PASSWORD=postgres
- PG_DB=postgres
- PG_PORT=5432
- PG_HOST=db
- HOST_IP=${HOST_IP}
depends_on:
- db
db:
container_name: db
image: postgres:12
environment:
- POSTGRES_DB=postgres
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata: {}
Where:
djangorestapp
is the deployment of our Django App, and as it can’t run without a Postgres database, adepends_on
value fordb
is defined. This prevents our app to initialize before the databasedb
is the Postgres database. It exposes the port5432
so we can connect to it, and persists its data in a Docker volume namedpgdata
- If we don’t want the Postgres data to be stored in the default Docker path, we can replace the volume name with an absolute or relative path of the host where it will run
.env
file
Some values in the docker-compose.yml
file can depend on the deployment environment, such as the ALLOWED_HOSTS
in the settings.py
(you need to specify the allowed IPs where HTTP requests can be queried). We’ll define a HOST_IP
variable in the .env
file, include it in the docker-compose.yml
and then pass it to the settings.py
file
At this point we should be able to deploy our application and start testing it.
Prepare a deployment server
Create an Alpine LXC with Docker
Follow the instructions in this article and in this other article to create an LXC and install Docker
Launch the project
From the project root directory (where the docker-compose.yml
file is), launch:
Test the application
Now the Django server should be reachable through <LXC-IP>:8000
. Configure some REST API requests using Postman and check that everything is correct.