What is Appliku?

Icon
Simplest way to deploy Python/Django apps

Push code to Git repo, Appliku will build & deploy the app to your cloud servers.

Learn more .

Start Deploying
Icon
SpeedPy.com
Django SaaS Project Template

Start building your app right from what makes your app unique

Get SpeedPy.Com
Icon
Appliku SaaS Discord Community

The place where you can talk to like minded individuals who are at different stages of building their SaaS or other apps.

Join Community

Building Django app with REST Framework API, OpenAPI 3 definitions with drf-spectacular

Share post:

Modern applications often consist of multiple parts, utilize other applications or used by other apps and tools.

In order to make communication between applications possible one application should expose API and another app should consume that API.

An example of such communication can be as simple as backend app that exposes REST API and web frontend or a mobile app talks to the backend.

Apps can be built of multiple components(services, microservices, etc), which are responsible for their own part in the bigger system and they would use REST API to communicate.

Also, an app can expose public API and users can build their own tools around the app's API.

In order to make using app's API easier developers should generate OpenAPI definitions.

These definitions will be used by developers of other apps to know how to interact with the API.

Even better – client API code can be generated from these definitions.

Having the ability to automatically generate the client code means that changes to the API can be easily applied to the client app code by simply running the generator.

Using such code is as easy as calling a function, without the need to worry about underlying protocols and formats.

In this tutorial I want to show how to build a Django app with REST Framework and create OpenAPI 3 definitions with awesome library drf-spectacular.

We'll also see how you can browse and interact with those definitions through web interface. We'll also see how you can import these definitions to REST API client tool called Insomnia.

In the next articles we'll cover topic of generating client API code using different tools for TypeScript and Python.

Objectives

In this tutorial I will build an app with product catalog.

App will have the following models: categories, products, product images and features.

We won't be building any frontend for this app, only REST API.

Source code

Github repository: https://github.com/appliku/drf_openapi3_tutorial

Requirements

I'll be using Django SaaS Boilerplate SpeedPy to quickly spin up our app, and I recommend you do the same.

While template has all the tools and settings needed for the task, I want to show what packages are needed:

Django
drf-spectacular

Docker must be installed on your computer in order to follow the tutorial.

Project setup

Go to https://speedpy.com specify a name for the project and copy the list of commands.

Paste that to your terminal.

A project will be created with the name that you specified.

I chose drf_openapi3_tutorial.

Generate a project from Django SaaS boilerplate

Among the template output there will be login and pasword listed for the admin user.

Please save that information, it will be needed soon.

Create models

Open main/models.py and let's add few models:

from django.db import models


class Category(models.Model):
    name = models.CharField(max_length=255)
    slug = models.SlugField(max_length=255, unique=True)

    def __str__(self):
        return self.name


class Product(models.Model):
    name = models.CharField(max_length=255)
    slug = models.SlugField(max_length=255, unique=True)
    category = models.ForeignKey(Category, on_delete=models.CASCADE)
    description = models.TextField()
    price = models.DecimalField(max_digits=6, decimal_places=2)

    def __str__(self):
        return self.name


class Feature(models.Model):
    name = models.CharField(max_length=255)
    description = models.TextField()

    def __str__(self):
        return self.name


class ProductFeature(models.Model):
    product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name='features')
    feature = models.ForeignKey(Feature, on_delete=models.CASCADE, related_name='features')
    ordering = models.PositiveIntegerField(default=0)

    def __str__(self):
        return self.product.name + ' - ' + self.feature.name


class ProductImage(models.Model):
    product = models.ForeignKey(Product, on_delete=models.CASCADE)
    image = models.ImageField(upload_to='product_images')
    is_main = models.BooleanField(default=False)

    def __str__(self):
        return self.product.name

Why I have created models like this is because I want to illustrate: - how to work with related objects, - how to upload file while using DRF on some models.

Let's also register these models in the main/admin.py:

from django.contrib import admin
from main import models


class CategoryAdmin(admin.ModelAdmin):
    pass


admin.site.register(models.Category, CategoryAdmin)


class ProductFeatureInline(admin.TabularInline):
    model = models.ProductFeature
    extra = 1


class ProductAdmin(admin.ModelAdmin):
    inlines = [ProductFeatureInline, ]


admin.site.register(models.Product, ProductAdmin)


class ProductImageAdmin(admin.ModelAdmin):
    pass


admin.site.register(models.ProductImage, ProductImageAdmin)


class FeatureAdmin(admin.ModelAdmin):
    pass


admin.site.register(models.Feature, FeatureAdmin)

This is pretty simple part, we have just registered models in the admin so we can edit them without much of customization, except one: we've made managing features on products with an inline class and added that class to Product.inlines

Now let's make migrations.

Typically you run python manage.py makemigrations main.

But all of these commands must be executed in docker, so run it with

docker compose python manage.py makemigrations main

If you used SpeedPy it includes a shortcut ./s which replaces docker compose run web python manage.py:

./s makemigrations main

And apply migrations

./s migrate

Building API

Now let's make API.

API consists of 2 main things: serializers and API views.

Let's start with serializers.

Create a file main/serializers.py:

from rest_framework import serializers
from main import models


class CategorySerializer(serializers.ModelSerializer):
    class Meta:
        model = models.Category
        fields = ('name', 'slug',)


class ProductFeatureSerializer(serializers.ModelSerializer):
    class Meta:
        model = models.ProductFeature
        fields = ('product', 'feature', 'ordering',)


class ProductSerializer(serializers.ModelSerializer):
    features = ProductFeatureSerializer(many=True)

    class Meta:
        model = models.Product
        fields = ('name', 'slug', 'category', 'description', 'price', 'features',)


class FeatureSerializer(serializers.ModelSerializer):
    class Meta:
        model = models.Feature
        fields = ('name', 'description',)


class ProductImageSerializer(serializers.ModelSerializer):
    class Meta:
        model = models.ProductImage
        fields = ('image', 'is_main', 'product',)

All of the serializers are ModelSerializers, means that they expect class Meta with model and fields specified.

The serializer ProductFeatureSerializer is used by ProductSerializer to display list of attached features.

features = ProductFeatureSerializer(many=True)

You can see many=True argument tells DRF there will be multple related obejcts.

If it was a one-to-one relationship then we'd write many=False or simply omit this argument.

The name for the field features will work because in the definition of ProductFeature model we have specified the related_name='features', so we can access the list of related ProductFeatures from Product instance via features attribute.

class ProductFeature(models.Model):
    product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name='features')
        ...

Now that we are done with serializers let's write API views. Or to be more specific I have used viewsets.

Viewsets allow you to write less code to achieve CRUD operations, easily add custom actions and have similar structure of URLs.

I also prefer to separate regular views from the API views.

Create a file for main/api.py:

from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import viewsets
from rest_framework.parsers import MultiPartParser, JSONParser
from rest_framework.permissions import IsAuthenticated

from main import models
from main import serializers


class CategoryViewSet(viewsets.ModelViewSet):
    """
    A viewset for viewing and editing categories.
    """
    permission_classes = (IsAuthenticated,)
    serializer_class = serializers.CategorySerializer
    queryset = models.Category.objects.all()


class ProductViewSet(viewsets.ModelViewSet):
    """
    A viewset for viewing and editing products.
    """
    permission_classes = (IsAuthenticated,)
    serializer_class = serializers.ProductSerializer
    queryset = models.Product.objects.all()


class FeatureViewSet(viewsets.ModelViewSet):
    """
    A viewset for viewing and editing features.
    """
    permission_classes = (IsAuthenticated,)
    serializer_class = serializers.FeatureSerializer
    queryset = models.Feature.objects.all()


class ProductImageViewSet(viewsets.ModelViewSet):
    """
    A viewset for viewing and editing product images.
    """
    permission_classes = (IsAuthenticated,)
    serializer_class = serializers.ProductImageSerializer
    queryset = models.ProductImage.objects.all()
    parser_classes = (MultiPartParser, JSONParser)
    filter_backends = [DjangoFilterBackend]
    filterset_fields = ['product', ]

Upload files and images with Django REST Framework

The default format in which client talk to the API is JSON. In this case client can't send binary data.

To be totally correct, it is possible but it requires additional conversion of files to base64 encoded string and then parsing it on the server side. This requires some additional setup and this option is outside the scope of the tutorial.

The easiest way to upload files is to specify the parser_classes attribute in the view. I have set it to the tuple of MultiPartParser, which will use the same type as when you send forms that support file upload by adding form attribute enctype="multipart/form-data", as well as JSONParser.

class ProductImageViewSet(viewsets.ModelViewSet):
    # ...
    parser_classes = (MultiPartParser, JSONParser)
        # ...

This will allow using both formats when submitting the data, but only with MultiPartParser files can be uploaded.

Client generated API will (usually) pick the first one, so the order matters. Make sure MultiPartParser is the first.

Creating URLs for DRF Viewsets

URLs for viewsets is one of the reasons why I loved viewsets in the first place. Instead of writing them manually, you can use DefaultRouter from rest_framework.routers.

Let's create a file main/api_urls.py and define our API URLs:


from rest_framework.routers import DefaultRouter

from main import api

router = DefaultRouter()
router.register(r'products', api.ProductViewSet, basename='products')
router.register(r'categories', api.CategoryViewSet, basename='categories')
router.register(r'features', api.FeatureViewSet, basename='features')
router.register(r'product_images', api.ProductImageViewSet, basename='product_images')

urlpatterns = router.urls

All we need to do here is define routes and set the urlpatterns variable that will be used by Django URL dispatcher.

Now let's add these urlpatterns to the main URLConf.

Open project/urls.py and add a line to the top of the existing urlpatterns list:

urlpatterns = [
    path('api/', include('main.api_urls')),  # add this line
    # ... the rest of the file

URLConf adding API urls

Using drf-spectacular to define OpenAPI schema

Why use OpenAPI?

Let's take a break for a second and talk about why you need schemas in the first place.

Back in a day I thought that building REST API is like making an endpoint that returns JSON and writing a piece of JS code on the frontend that fetches the endpoint URL and does something with this JSON.

Today I have this phrase: If you think you are building API and you don't provide schema, then you are just building a bunch of hidden URLs.

When I started working with frontend codebase that relied on the backend API, at first I was building everything manually, haven't even tried viewsets and was maintaining common URL structure manually.

After a while it became such a burden to maintain this API! Every time I change something on the backend, I have to update type definitions on the frontend, as well as update functions that talk to API. Flexibility came at great cost and development slowed down drastically.

Then I started working on another project where frontend wasn't my responsibility. I created proper API schema definitions and helped the frontend person to setup the client code generation.

Since we worked each on our parts of the code, one might think we'll have problems making changes to the API: URLs, parameters, types, expected return types and so on.

But we didn't have such problems.

When I change something on the API, the frontend guy would just regenerate client API code from the definitions and try to build his app.

If something has change he would see problems, thanks to typing, and adapt his code to the changes. I don't need to tell him what has changed, I just tell him to rebuild the client code.

That easy!

Setting up drf-spectacular

While SpeedPy has it all in place already, let's go over what's needed to setup drf-spectacular the right way. Part of this will be copied from the official drf-spectacular docs and then I expand it a bit.

  1. First of all the package drf-spectacular should be in project's requirements.txt.
  2. add drf_spectacular to the INSTALLED_APPS list in your settings.py
  3. register spectacular AutoSchema with DRF:
   REST_FRAMEWORK = {
         'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
     }
  1. Now specify Spectacular Settings.
SPECTACULAR_SETTINGS = {
        'TITLE': 'SpeedPyCom API',
        'DESCRIPTION': 'API documentation for SpeedPyCom based app',
        'VERSION': '1.0.0',
        'SERVE_INCLUDE_SCHEMA': False,
                'COMPONENT_SPLIT_REQUEST': True
        # OTHER SETTINGS
    }

Look at one important key in this dictionary: COMPONENT_SPLIT_REQUEST.

It will make Spectacular generate separate definitions for read, create and update requests. This in turn allows client code generation to make separate types for read obejcts, create objects and patch/update objects.

Here are some examples of enabling COMPONENT_SPLIT_REQUEST: - if a serializer has a read_only=True field, then such a property for this field will only exist on the object that is returned by the API. - the same way a serializer field with write_only=True attribute will only exist on type of object you pass to create an instance(PUT operation) or update (PATCH operation). - on the definition/type for PATCH operation pretty much all fields will be optional.

This allows the developer of client app to have exactly correct attributes on those objects, type checking and not doing workarounds around the generated types.

  1. URLs for API Schema:

In your project/urls.py (or other path where your root URLConf is specified) add these lines to the urlpatterns list:

from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView

urlpatterns = [
    # ...
    # API Schema:
    path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
    # Optional UI:
    path('api/schema/swagger-ui/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),
    path('api/schema/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'),
        # ...
]

That's all for the setup.

Now run your app.

docker compose up

And open your browser http://0.0.0.0:8060/api/schema/swagger-ui/ (This is the default settings for SpeedPy template, your port can be different, please see output of the Django runserver command to see your URL).

OpenAPI Swagger interface

Also you can open redoc interface: http://0.0.0.0:8060/api/schema/redoc/

OpenAPI Redoc Interface

Importing definitions into Insomnia

Insomnia is a great app for testing and trying APIs.

You can import the definitions schema by providing the schema URL http://0.0.0.0:8060/api/schema

Import OpenAPI Schema definition into Desktop App Insomnia

Import OpenAPI Schema

Click "New" if this is the first time you are importing definitions for this project Import as new

Choose "Request Collection" on the next step Request Collection

Click on your newly imported requests collection

Request Collection

What's next

In the next articles I will write about: - generating client API python code with the official openapi docker image - generating client API TypeScript code and building NextJS app that utilizes the API built in this tutorial!

Thanks for reading!

Want to deploy this tutorial? Sign up for Appliku now: https://app.appliku.com/ and enjoy cost efficient and easy deployments!

Share post:
Top