Build professional REST APIs with Django REST Framework (DRF). Learn serializers, ViewSets, routers, authentication, permissions, filtering, pagination, and testing REST APIs in Django.
Django REST Framework (DRF) is the most popular toolkit for building REST APIs in Django. It provides serializers, authentication, permissions, and viewsets that make API development fast and reliable.
Installation
pip install djangorestframework
pip install djangorestframework-simplejwt # For JWT auth
pip install django-filter # For advanced filtering
pip install drf-spectacular # For OpenAPI/Swagger docs
# settings.py
INSTALLED_APPS = [
...
'rest_framework',
'rest_framework_simplejwt',
'django_filters',
'drf_spectacular',
]
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework_simplejwt.authentication.JWTAuthentication',
'rest_framework.authentication.SessionAuthentication',
],
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticatedOrReadOnly',
],
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
'PAGE_SIZE': 20,
'DEFAULT_FILTER_BACKENDS': [
'django_filters.rest_framework.DjangoFilterBackend',
'rest_framework.filters.SearchFilter',
'rest_framework.filters.OrderingFilter',
],
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
}
Serializers
Serializers convert model instances to/from JSON:
# blog/serializers.py
from rest_framework import serializers
from django.contrib.auth.models import User
from .models import Post, Category, Comment, Tag
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ['id', 'username', 'email', 'first_name', 'last_name']
read_only_fields = ['id']
class TagSerializer(serializers.ModelSerializer):
class Meta:
model = Tag
fields = ['id', 'name', 'slug']
class CategorySerializer(serializers.ModelSerializer):
post_count = serializers.SerializerMethodField()
class Meta:
model = Category
fields = ['id', 'name', 'slug', 'description', 'post_count']
def get_post_count(self, obj):
return obj.post_set.filter(status='published').count()
class CommentSerializer(serializers.ModelSerializer):
author_username = serializers.ReadOnlyField(source='author.username')
class Meta:
model = Comment
fields = ['id', 'author_username', 'content', 'created_at']
read_only_fields = ['id', 'author_username', 'created_at']
class PostListSerializer(serializers.ModelSerializer):
"""Lighter serializer for list views."""
author = UserSerializer(read_only=True)
category_name = serializers.ReadOnlyField(source='category.name')
tags = TagSerializer(many=True, read_only=True)
comment_count = serializers.SerializerMethodField()
class Meta:
model = Post
fields = [
'id', 'title', 'slug', 'author', 'category_name',
'tags', 'excerpt', 'status', 'views_count',
'comment_count', 'created_at', 'published_at'
]
read_only_fields = ['id', 'views_count', 'created_at']
def get_comment_count(self, obj):
return obj.comments.filter(approved=True).count()
class PostDetailSerializer(serializers.ModelSerializer):
"""Full serializer for detail views."""
author = UserSerializer(read_only=True)
category = CategorySerializer(read_only=True)
category_id = serializers.PrimaryKeyRelatedField(
queryset=Category.objects.all(),
source='category',
write_only=True,
required=False
)
tags = TagSerializer(many=True, read_only=True)
tag_ids = serializers.PrimaryKeyRelatedField(
queryset=Tag.objects.all(),
source='tags',
many=True,
write_only=True,
required=False
)
comments = CommentSerializer(many=True, read_only=True)
class Meta:
model = Post
fields = [
'id', 'title', 'slug', 'author', 'category', 'category_id',
'tags', 'tag_ids', 'content', 'excerpt', 'status',
'is_featured', 'views_count', 'comments', 'created_at',
'updated_at', 'published_at'
]
read_only_fields = ['id', 'slug', 'views_count', 'created_at', 'updated_at']
def validate_title(self, value):
if len(value) < 5:
raise serializers.ValidationError("Title must be at least 5 characters.")
return value
def validate(self, data):
"""Cross-field validation."""
if data.get('status') == 'published' and not data.get('excerpt'):
raise serializers.ValidationError(
{'excerpt': 'Excerpt is required when publishing a post.'}
)
return data
def create(self, validated_data):
tags = validated_data.pop('tags', [])
post = Post.objects.create(**validated_data)
post.tags.set(tags)
return post
def update(self, instance, validated_data):
tags = validated_data.pop('tags', None)
for attr, value in validated_data.items():
setattr(instance, attr, value)
instance.save()
if tags is not None:
instance.tags.set(tags)
return instance
API Views
Function-Based API Views
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework import status
@api_view(['GET', 'POST'])
def post_list(request):
if request.method == 'GET':
posts = Post.objects.filter(status='published')
serializer = PostListSerializer(posts, many=True)
return Response(serializer.data)
elif request.method == 'POST':
serializer = PostDetailSerializer(data=request.data)
if serializer.is_valid():
serializer.save(author=request.user)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
Class-Based API Views
from rest_framework.views import APIView
from rest_framework.generics import (
ListCreateAPIView, RetrieveUpdateDestroyAPIView,
ListAPIView, RetrieveAPIView
)
class PostListCreateView(ListCreateAPIView):
serializer_class = PostListSerializer
def get_queryset(self):
return Post.objects.filter(status='published').select_related('author', 'category')
def perform_create(self, serializer):
serializer.save(author=self.request.user)
class PostRetrieveUpdateDestroyView(RetrieveUpdateDestroyAPIView):
queryset = Post.objects.all()
lookup_field = 'slug'
def get_serializer_class(self):
if self.request.method == 'GET':
return PostDetailSerializer
return PostDetailSerializer
def perform_update(self, serializer):
serializer.save()
def destroy(self, request, *args, **kwargs):
instance = self.get_object()
if instance.author != request.user:
return Response({'error': 'Permission denied'}, status=status.HTTP_403_FORBIDDEN)
self.perform_destroy(instance)
return Response(status=status.HTTP_204_NO_CONTENT)
ViewSets and Routers
ViewSets combine CRUD logic into a single class:
# blog/views.py
from rest_framework import viewsets, permissions, filters, status
from rest_framework.decorators import action
from rest_framework.response import Response
from django_filters.rest_framework import DjangoFilterBackend
class PostViewSet(viewsets.ModelViewSet):
"""
Full CRUD API for blog posts.
list: GET /api/posts/
create: POST /api/posts/
retrieve: GET /api/posts/{slug}/
update: PUT /api/posts/{slug}/
partial_update: PATCH /api/posts/{slug}/
destroy: DELETE /api/posts/{slug}/
"""
queryset = Post.objects.all().select_related('author', 'category').prefetch_related('tags')
lookup_field = 'slug'
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
filterset_fields = ['status', 'category', 'author', 'is_featured']
search_fields = ['title', 'content', 'author__username']
ordering_fields = ['created_at', 'views_count', 'title']
ordering = ['-created_at']
def get_serializer_class(self):
if self.action == 'list':
return PostListSerializer
return PostDetailSerializer
def get_permissions(self):
if self.action in ['list', 'retrieve']:
return [permissions.AllowAny()]
return [permissions.IsAuthenticated()]
def get_queryset(self):
queryset = super().get_queryset()
if self.action == 'list':
queryset = queryset.filter(status='published')
return queryset
def perform_create(self, serializer):
serializer.save(author=self.request.user)
def perform_update(self, serializer):
if serializer.instance.author != self.request.user:
raise PermissionError("You can only edit your own posts.")
serializer.save()
# Custom actions
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated])
def publish(self, request, slug=None):
"""Publish a draft post. POST /api/posts/{slug}/publish/"""
post = self.get_object()
if post.author != request.user:
return Response({'error': 'Permission denied'}, status=status.HTTP_403_FORBIDDEN)
post.status = 'published'
from django.utils import timezone
post.published_at = timezone.now()
post.save()
return Response({'message': 'Post published successfully', 'status': post.status})
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated])
def like(self, request, slug=None):
"""Like a post. POST /api/posts/{slug}/like/"""
post = self.get_object()
user = request.user
if user in post.liked_by.all():
post.liked_by.remove(user)
return Response({'liked': False, 'likes': post.liked_by.count()})
else:
post.liked_by.add(user)
return Response({'liked': True, 'likes': post.liked_by.count()})
@action(detail=False, methods=['get'], permission_classes=[permissions.IsAuthenticated])
def my_posts(self, request):
"""Get current user's posts. GET /api/posts/my_posts/"""
posts = Post.objects.filter(author=request.user)
page = self.paginate_queryset(posts)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(posts, many=True)
return Response(serializer.data)
@action(detail=False, methods=['get'])
def featured(self, request):
"""Get featured posts. GET /api/posts/featured/"""
featured_posts = Post.objects.filter(status='published', is_featured=True)
serializer = PostListSerializer(featured_posts, many=True)
return Response(serializer.data)
class CategoryViewSet(viewsets.ReadOnlyModelViewSet):
"""Read-only API for categories."""
queryset = Category.objects.all()
serializer_class = CategorySerializer
lookup_field = 'slug'
@action(detail=True, methods=['get'])
def posts(self, request, slug=None):
"""Get posts for a category. GET /api/categories/{slug}/posts/"""
category = self.get_object()
posts = Post.objects.filter(category=category, status='published')
serializer = PostListSerializer(posts, many=True)
return Response(serializer.data)
URL Router Configuration
# blog/urls.py
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from . import views
router = DefaultRouter()
router.register(r'posts', views.PostViewSet, basename='post')
router.register(r'categories', views.CategoryViewSet, basename='category')
urlpatterns = [
path('api/', include(router.urls)),
]
# Generated routes:
# GET /api/posts/ → PostViewSet.list
# POST /api/posts/ → PostViewSet.create
# GET /api/posts/{slug}/ → PostViewSet.retrieve
# PUT /api/posts/{slug}/ → PostViewSet.update
# PATCH /api/posts/{slug}/ → PostViewSet.partial_update
# DELETE /api/posts/{slug}/ → PostViewSet.destroy
# POST /api/posts/{slug}/publish/ → PostViewSet.publish
# POST /api/posts/{slug}/like/ → PostViewSet.like
# GET /api/posts/my_posts/ → PostViewSet.my_posts
# GET /api/posts/featured/ → PostViewSet.featured
Authentication
JWT Authentication
# settings.py
from datetime import timedelta
SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60),
'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
'ROTATE_REFRESH_TOKENS': True,
'BLACKLIST_AFTER_ROTATION': True,
'ALGORITHM': 'HS256',
'AUTH_HEADER_TYPES': ('Bearer',),
}
# accounts/urls.py
from django.urls import path
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView, TokenVerifyView
urlpatterns = [
path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
path('api/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
path('api/token/verify/', TokenVerifyView.as_view(), name='token_verify'),
]
# Login: POST /api/token/ → {"username": "...", "password": "..."}
# Returns: {"access": "...", "refresh": "..."}
# Refresh: POST /api/token/refresh/ → {"refresh": "..."}
Custom Permissions
# blog/permissions.py
from rest_framework.permissions import BasePermission, SAFE_METHODS
class IsOwnerOrReadOnly(BasePermission):
"""Allow owners to edit, everyone to read."""
def has_object_permission(self, request, view, obj):
if request.method in SAFE_METHODS:
return True
return obj.author == request.user
class IsAdminOrReadOnly(BasePermission):
"""Allow admins to write, everyone to read."""
def has_permission(self, request, view):
if request.method in SAFE_METHODS:
return True
return request.user and request.user.is_staff
class IsVerifiedUser(BasePermission):
"""Only allow verified users."""
message = 'Please verify your email address.'
def has_permission(self, request, view):
return (
request.user and
request.user.is_authenticated and
getattr(request.user, 'email_verified', False)
)
API Testing
# blog/tests.py
from rest_framework.test import APITestCase, APIClient
from rest_framework import status
from django.contrib.auth.models import User
from .models import Post, Category
class PostAPITests(APITestCase):
def setUp(self):
# Create test user
self.user = User.objects.create_user(
username='testuser',
password='testpass123',
email='test@test.com'
)
self.client = APIClient()
# Create test category
self.category = Category.objects.create(name='Tech', slug='tech')
# Create test post
self.post = Post.objects.create(
title='Test Post',
slug='test-post',
author=self.user,
content='Test content',
status='published'
)
def test_list_posts_unauthenticated(self):
"""Anyone can list published posts."""
response = self.client.get('/api/posts/')
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data['results']), 1)
def test_create_post_authenticated(self):
"""Authenticated users can create posts."""
self.client.force_authenticate(user=self.user)
data = {
'title': 'New Post',
'content': 'New content here',
'excerpt': 'Summary',
'status': 'published',
'category_id': self.category.id
}
response = self.client.post('/api/posts/', data, format='json')
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(Post.objects.count(), 2)
def test_create_post_unauthenticated(self):
"""Unauthenticated users cannot create posts."""
data = {'title': 'Unauthorized Post', 'content': 'Content'}
response = self.client.post('/api/posts/', data, format='json')
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_update_own_post(self):
"""Users can update their own posts."""
self.client.force_authenticate(user=self.user)
data = {'title': 'Updated Title', 'content': 'Updated content'}
response = self.client.patch(f'/api/posts/{self.post.slug}/', data, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.post.refresh_from_db()
self.assertEqual(self.post.title, 'Updated Title')
def test_delete_post(self):
"""Users can delete their own posts."""
self.client.force_authenticate(user=self.user)
response = self.client.delete(f'/api/posts/{self.post.slug}/')
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
self.assertEqual(Post.objects.count(), 0)
# Run tests:
# python manage.py test blog
Summary
In this lesson, you learned:
- ✅ Installing and configuring Django REST Framework
- ✅ Creating ModelSerializers with validation
- ✅ Function-based and class-based API views
- ✅ ViewSets with full CRUD and custom actions
- ✅ URL routing with DRF Router
- ✅ JWT authentication setup
- ✅ Custom permission classes
- ✅ Writing API tests with APITestCase
*Next Section: Data Science →*