Creating RESTful API endpoints with DRF
The most popular and widely used API is the REST API. Throughout this book, we shall be working with the REST API. REST has been around for more than two decades, and every company has its interpretation and implementation. In the following section, we shall try to put all the best practices used in the industry into practice.
Opinionated note
The RESTful API is not a protocol; instead, it is a standard convention that developers follow. There is no right or wrong while designing RESTful APIs. Since there is no enforced standard, the details we will provide are purely opinionated and come from my past experiences. You are free to pick the points you like and leave out the things that you feel are not relevant to your implementations.
Best practices for defining RESTful APIs
Let’s look at a few generic good practices that developers use in the industry while defining RESTful endpoints:
- Using nouns instead of verbs in endpoint paths using appropriate HTTP request methods. Here are some examples (please note that the URL example used here is just an outline of how we should define our REST URLs and that we are not defining the exact code):
# To get all blogs Avoid GET /get-all-blogs, rather use GET /blogs # To delete a particular blog Avoid POST /delete-blog rather use DELETE /blogs/<blogId> # To create a new blog with POST request Avoid POST /create-new-blog rather use POST /blogs # To update an existing blog with a PUT request Avoid PUT /update-blog rather use PUT /blogs/<blogId>
- Using the appropriate HTTP method is preferred to perform CRUD operations. There are multiple HTTP methods present, but we shall only cover the top five commonly used methods:
GET
: To retrieve an entity, be it a list or detailPOST
: To create any new entityPUT
: To Update an entityPATCH
: To partially update an entityDELETE
: To delete an entity
- It is preferred to create plural nouns in the endpoint. When you have to get a single entry, then use
id
after the endpoint to retrieve the information. For example, to get a list of blogs, useGET /blogs
, and to get the details of one blog, useGET /
blogs/<blog id>
. - Using a logical nested structure for an endpoint is important to clean the API interface and maintain a good information architecture. For example, to get all the comments for a particular blog, the API should be
GET /
blogs/<blog id>/comments
. - Versioning the API is important since it helps support legacy systems without breaking the contract in newer systems. Examples of this are
/v1/blogs/
and/v2/blogs
. We will learn more about this later, in the Using API versioning section. - Servers should send appropriate HTTP response status codes as per the action, along with the message body (if applicable). Here are a few of the most widely used HTTP status codes:
- 2xx: Used for any success. For example,
200
is for any request responding with the data successfully,201
is for creating a new entry, and so on. - 3xx: Used for any redirection.
- 4xx: For any error. For example, use
400
for bad requests and404
for requested data not found. - 5xx: When the server crashes due to any unexpected request or the server is unavailable.
- 2xx: Used for any success. For example,
- The server must accept and respond with a JSON response. The API will not support other data types, such as plain text, XML, and others.
Best practices to create a REST API with DRF
DRF is a framework that helps us create the REST endpoint faster. It’s the responsibility of the developer to write scalable and maintainable code while following the best practices. Let’s look at some best practices that we can implement using DRF.
Using API versioning
Creating versions of an API is probably the most important thing to follow when working with clients whose updates are not under your control. An example of this is working on a mobile app. Once an end user installs a given mobile app, with a given API integrated, we have to support the given API until the end user updates the mobile app version with the newer API.
While creating an endpoint, a developer should consider all the future requirements possible, along with all the corner cases. However, just like it is not possible to predict the future, a developer cannot always foresee how the current API design might have to be redesigned. A redesign would mean breaking the contract between the client and the server. This is when the importance of API versioning comes into the picture. A well-versioned API will implement a new contract without breaking any of the existing clients.
There are multiple ways to implement API versioning:
- Accept header versioning: Since the version is passed through the
Accept
header, whenever there is a new version, the client doesn’t need to update any endpoint whenever a new version is created:GET /bookings/ HTTP/1.1 Host: example.com Accept: application/json; version=1.0
- URL path versioning: The API version is passed through the URL path pattern. This is one of the most widely used API versioning methods due to it providing better visibility:
GET /v1/bookings/ HTTP/1.1 Host: example.com Accept: application/json
- Query parameter versioning: The query parameter in the URL contains the version. After URL path-based versioning, this is the second most common versioning method due to its cleaner interface and better discoverability:
GET /something/?version=1.0 HTTP/1.1 Host: example.com Accept: application/json
- Host name versioning: This involves using the subdomain to pass the API version in the hostname. This kind of versioning is used when someone migrates the whole service to a newer version rather than single endpoints:
GET /bookings/ HTTP/1.1 Host: v1.example.com Accept: application/json
DRF supports all four methods of API versioning out of the box and also gives the option to create our custom API version logic if needed. We shall explore URLPathVersioning primarily since it is one of the easiest and most popular ways of implementing versioning using DRF.
In URL path versioning, the API version is passed in the URL path, which makes it easy to identify on both the client and server side. To integrate URLPathVersioning
in DRF, add the following in the Django config/settings.py
file:
REST_FRAMEWORK = { 'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.URLPathVersioning' }
Now, we must add the version to the URL path. It is important to name the URL parameter <version>
since DRF is expecting it to be <version>
by default. Here, <version>
is the URL’s pattern, which means that any URL that matches this pattern shall be linked to the views.
Important note
To learn more about urlpatterns
, go to https://docs.djangoproject.com/en/stable/topics/http/urls/.
It is advisable to add <version>
at the beginning of the URL, so let’s do that in the main config/urls.py
file:
urlpatterns = [ path('admin/', admin.site.urls), path('<version>/demo-app-version/', include('demo_app.urls')) ]
Once we have configured the URL with the <version>
, we can try to create a new view and retrieve the version in our view. Add the following code to your demo_app/urls.py
file:
from django.urls import path from demo_app import views urlpatterns = [ path('hello-world/', views.hello_world), path('demo-version/', views.demo_version), ]
We shall retrieve the API version in the view and return the version in response:
@api_view(['GET']) def demo_version(request, *args, **kwargs): version = request.version return Response(data={ 'msg': f'You have hit {version} of demo-api' })
Now, when we open http://127.0.0.1:8000/v1/demo-app-version/demo-version/
, we should be able to see the following screen:
Figure 1.7: Output showing which version we have hit for the given API
If we change the URL to http://127.0.0.1:8000/v9/demo-app/demo-version/
, then we’ll see that it returns v9. v9 might not have been released yet, so this might create confusion for the end user hitting the endpoint. To solve this problem, we shall see how we can customize the version class of DRF so that we can add constraints that can help us design better applications.
Using a custom version class with DRF
Let’s see how we can extend the URLPathVersioning
class provided by DRF to address the problem we just raised. First, create a file called demo_app/custom_versions.py
. This file will have a custom version class for each view, along with a default class for all the views that don’t have multiple versions yet:
from rest_framework.versioning import URLPathVersioning class DefaultDemoAppVersion(URLPathVersioning): allowed_versions = ['v1'] version_param = 'version' class DemoViewVersion(DefaultDemoAppVersion): allowed_versions = ['v1', 'v2', 'v3'] class AnotherViewVersion(DefaultDemoAppVersion): allowed_versions = ['v1', 'v2']
Let’s see what the preceding code does:
- The
DefaultDemoAppVersion
class can be used for all the views that are created indemo_app
. It has anallowed_versions
attribute that lists all the allowed versions that can be used in the URL path whenever we use this class-based view.version_param
is the URL path parameter name that we have used to define the version; it can be anything, depending on how you name the parameter, but in our case, we are using<version>
, which is used in theconfig/urls.py
file. This class will be used for all the views that are created in the demo app by default until a new version is added, after which we will create an individual class, as shown next. - The
DemoViewVersion
class will contain the list of all theallowed_versions
attributes forDemoView
that are allowed in the URL path. - The
AnotherViewVersion
class will contain all the versions that are allowed for a different class.
Add the following code to the demo_app/views.py
file to integrate the custom version class (note that the custom versioning_class
can be only linked to a class-based view, so we are using APIView
here):
from rest_framework.response import Response from rest_framework.views import APIView from demo_app import custom_versions class DemoView(APIView): versioning_class = custom_versions.DemoViewVersion def get(self, request, *args, **kwargs): version = request.version return Response(data={'msg': f' You have hit {version}'}) class AnotherView(APIView): versioning_class = custom_versions.AnotherViewVersion def get(self, request, *args, **kwargs): version = request.version if version == 'v1': # perform v1 related tasks return Response(data={'msg': 'v1 logic'}) elif version == 'v2': # perform v2 related tasks return Response(data={'msg': 'v2 logic'})
Let’s explore the code and understand what is happening under the hood when we use the custom version class:
- The
DemoView
class is a class-basedAPIView
where we are passing the allowed versions for the view by theversioning_class
attribute. This allows the request object to have a version attribute that is parsed from the URL path. Since we have specified theDemoViewVersion
class, this view will only allow thev1
,v2
, andv3
versions in the URL path. Any other version in the path will result in a404
response. - The
AnotherView
class is a class-based view where we are passingAnotherViewVersion
as theversioning_class
attribute. In this view, we are bifurcating the request by checking different versions and responding differently whenever we have av1
orv2
request.
Now, to link the view logic to the demo_app/urls.py
file, add the following code:
urlpatterns = [ path('hello-world/', views.hello_world), path('demo-version/', views.demo_version), path('custom-version/', views.DemoView.as_view()), path('another-custom-version/', views.AnotherView.as_view()) ]
If we go to http://127.0.0.1:8000/v4/demo-app-version/custom-version/
in our browser, we shall see the following error as shown in Figure 1.8, since we have only allowed three versions in our custom versioning_class
:
Figure 1.8: 404 error message stating “Invalid version in URL path”
This serves our purpose of only allowing certain versions of the API; any other API version shall result in an error response.
Important note
Custom versioning can only be attached to class-based views. If you don’t pass any custom versioning_class
, then Django will pick DEFAULT_VERSIONING_CLASS
from the default settings.
Avoid Router
Frameworks such as Ruby on Rails provide the functionality to automatically map requests to a given pattern of URLs, depending on the functionality. DRF borrowed this concept and incorporated it into the framework as the Routers feature. Though this is a wonderful concept to learn and experiment with, developers should avoid it in production since this goes against the principle of Django: “Explicit is better than implicit.”
Django mentions that it should not show too much of the magic. I have personally seen legacy systems where developers added Router and after a couple of months, when a different developer wanted to fix a bug in the view, they were unable to find the corresponding view directly before having the “aha!” moment of identifying the Router concept.
Opinionated note
Avoiding the use of Router is something I have learned the hard way and have seen multiple developers avoid in production. But this is also an opinion that was developed through a bad experience; you can always try to implement it in a better way in your project.
If you want to learn more about Router, you can do so here: https://www.django-rest-framework.org/api-guide/routers/.
With that, we’ve learned how to create RESTful APIs and work with versioning. Now, let’s learn how to work with views using DRF. We mainly write business logic inside views.