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
.
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.
Nested Django REST Framework Serializer for related objects
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
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.
- First of all the package
drf-spectacular
should be in project'srequirements.txt
. - add
drf_spectacular
to theINSTALLED_APPS
list in your settings.py - register spectacular AutoSchema with DRF:
REST_FRAMEWORK = {
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
}
- 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.
- 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).
Also you can open redoc interface: http://0.0.0.0:8060/api/schema/redoc/
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
Click "New" if this is the first time you are importing definitions for this project
Choose "Request Collection" on the next step
Click on your newly imported requests 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!