Streamlining API Development
So far, we've learned about serializers for data transformation and various types of views for handling HTTP requests. Now we'll explore ViewSets and Routers, which further simplify API development by abstracting common patterns and reducing boilerplate code.
ViewSets and Routers represent the highest level of abstraction in Django REST Framework, combining multiple related views into a single class and automatically generating URL patterns.
Analogy: The Smart Home System
Think of building an API as constructing a smart home:
- Function-based views are like manually controlling each device with its own remote - explicit but repetitive
- Class-based views are like having a universal remote for each room - better organized but still requiring manual setup for each room
- ViewSets are like a central smart home hub that automatically controls all devices using consistent patterns - "I want to turn on lights" works the same in every room
- Routers are like the voice command system that knows which commands to route to which devices without you having to specify the details
Just as a smart home system reduces the cognitive load of managing multiple devices, ViewSets and Routers reduce the mental overhead of creating and maintaining consistent RESTful APIs.
Understanding ViewSets
A ViewSet is a class that combines the logic for multiple related views in a single class. Instead of defining separate views for listing, retrieving, creating, updating, and deleting resources, a ViewSet handles all these operations.
ViewSets map standard HTTP methods to actions:
| HTTP Method | ViewSet Action | URL Pattern | Description |
|---|---|---|---|
| GET | list | /books/ | Get a list of all books |
| POST | create | /books/ | Create a new book |
| GET | retrieve | /books/{id}/ | Get a specific book |
| PUT | update | /books/{id}/ | Update a specific book (complete) |
| PATCH | partial_update | /books/{id}/ | Update a specific book (partial) |
| DELETE | destroy | /books/{id}/ | Delete a specific book |
Creating Your First ViewSet
Let's create a simple ViewSet for our Book model:
# views.py
from rest_framework import viewsets
from .models import Book
from .serializers import BookSerializer
class BookViewSet(viewsets.ModelViewSet):
"""
A viewset for viewing and editing book instances.
"""
queryset = Book.objects.all()
serializer_class = BookSerializer
That's it! With just these few lines, we've created a complete API that handles all CRUD operations.
The ModelViewSet class automatically provides:
- List view (GET /books/)
- Detail view (GET /books/1/)
- Create view (POST /books/)
- Update view (PUT /books/1/)
- Partial update view (PATCH /books/1/)
- Delete view (DELETE /books/1/)
Read-Only ViewSet
If you need a read-only API, you can use ReadOnlyModelViewSet:
# views.py
from rest_framework import viewsets
from .models import Book
from .serializers import BookSerializer
class BookViewSet(viewsets.ReadOnlyModelViewSet):
"""
A viewset for viewing book instances.
"""
queryset = Book.objects.all()
serializer_class = BookSerializer
This provides only the list and retrieve actions, omitting the create, update, and delete functionality.
ViewSet Actions and Methods
ViewSets provide standard actions, but you can also add custom actions:
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
from .models import Book
from .serializers import BookSerializer, ReviewSerializer
class BookViewSet(viewsets.ModelViewSet):
queryset = Book.objects.all()
serializer_class = BookSerializer
@action(detail=True, methods=['post'])
def mark_bestseller(self, request, pk=None):
book = self.get_object()
book.is_bestseller = True
book.save()
serializer = self.get_serializer(book)
return Response(serializer.data)
@action(detail=True, methods=['get'])
def reviews(self, request, pk=None):
book = self.get_object()
reviews = book.reviews.all()
page = self.paginate_queryset(reviews)
if page is not None:
serializer = ReviewSerializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = ReviewSerializer(reviews, many=True)
return Response(serializer.data)
@action(detail=False, methods=['get'])
def bestsellers(self, request):
bestsellers = self.get_queryset().filter(is_bestseller=True)
page = self.paginate_queryset(bestsellers)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(bestsellers, many=True)
return Response(serializer.data)
The @action decorator adds custom endpoints to your ViewSet:
detail=Truecreates a route that acts on a single object (e.g., /books/1/reviews/)detail=Falsecreates a route that acts on the entire collection (e.g., /books/bestsellers/)methodsspecifies which HTTP methods the action accepts
Resulting URLs
These custom actions create the following additional endpoints:
- POST /books/1/mark_bestseller/ - Mark a specific book as a bestseller
- GET /books/1/reviews/ - Get all reviews for a specific book
- GET /books/bestsellers/ - Get all bestseller books
Routers: Automatic URL Configuration
Routers automatically generate URL patterns for ViewSets, following REST conventions and reducing duplication.
Basic Router Setup
# urls.py
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from . import views
router = DefaultRouter()
router.register(r'books', views.BookViewSet)
urlpatterns = [
path('api/', include(router.urls)),
]
This simple configuration creates the following URL patterns:
- GET /api/books/ - List all books
- POST /api/books/ - Create a new book
- GET /api/books/{pk}/ - Retrieve a specific book
- PUT /api/books/{pk}/ - Update a specific book
- PATCH /api/books/{pk}/ - Partially update a specific book
- DELETE /api/books/{pk}/ - Delete a specific book
- GET /api/books/{pk}/mark_bestseller/ - Mark a book as bestseller (custom action)
- GET /api/books/{pk}/reviews/ - Get reviews for a book (custom action)
- GET /api/books/bestsellers/ - Get all bestsellers (custom action)
Router Types
DRF provides several router classes:
- SimpleRouter: Basic routing without a root API view
- DefaultRouter: Adds a root API view with hyperlinks to all registered ViewSets
Multiple ViewSets with a Router
# urls.py
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from . import views
router = DefaultRouter()
router.register(r'books', views.BookViewSet)
router.register(r'authors', views.AuthorViewSet)
router.register(r'publishers', views.PublisherViewSet)
urlpatterns = [
path('api/', include(router.urls)),
]
The router automatically creates all the necessary URL patterns for each ViewSet, maintaining consistent RESTful conventions across your API.
ViewSet Customization with Mixins
If you need more control over which actions are available, you can build a ViewSet using mixins:
from rest_framework import viewsets, mixins
from .models import Book
from .serializers import BookSerializer
class BookViewSet(mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.CreateModelMixin,
viewsets.GenericViewSet):
"""
A viewset that provides `list`, `retrieve`, and `create` actions.
"""
queryset = Book.objects.all()
serializer_class = BookSerializer
This ViewSet provides only list, retrieve, and create functionality, omitting update and delete.
Available Mixins
DRF provides the following mixins for ViewSets:
- ListModelMixin: Provides list action
- RetrieveModelMixin: Provides retrieve action
- CreateModelMixin: Provides create action
- UpdateModelMixin: Provides update and partial_update actions
- DestroyModelMixin: Provides destroy action
By combining these mixins, you can create ViewSets with exactly the functionality you need.
Filtering, Searching, and Ordering
ViewSets can be extended with filtering capabilities to enable clients to narrow down results:
from rest_framework import viewsets
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import SearchFilter, OrderingFilter
from .models import Book
from .serializers import BookSerializer
class BookViewSet(viewsets.ModelViewSet):
queryset = Book.objects.all()
serializer_class = BookSerializer
# Enable filtering backends
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
# Fields that can be filtered via query params
filterset_fields = ['author', 'published_date', 'is_bestseller']
# Fields that can be searched with ?search=query
search_fields = ['title', 'description', 'author__name']
# Fields that can be ordered with ?ordering=field
ordering_fields = ['title', 'published_date', 'price']
# Default ordering
ordering = ['-published_date']
This configuration enables:
- Filtering:
/api/books/?author=1&is_bestseller=true - Searching:
/api/books/?search=django - Ordering:
/api/books/?ordering=titleor/api/books/?ordering=-price
Custom Filtering
For more complex filtering, you can override the get_queryset method:
class BookViewSet(viewsets.ModelViewSet):
serializer_class = BookSerializer
def get_queryset(self):
queryset = Book.objects.all()
# Filter by year of publication
year = self.request.query_params.get('year')
if year:
queryset = queryset.filter(published_date__year=year)
# Filter by minimum rating
min_rating = self.request.query_params.get('min_rating')
if min_rating:
# Assuming there's a method to calculate average rating
queryset = queryset.annotate(avg_rating=Avg('reviews__rating'))
queryset = queryset.filter(avg_rating__gte=min_rating)
return queryset
Pagination
ViewSets automatically use the pagination settings defined in your settings.py, but you can also configure pagination per ViewSet:
from rest_framework import viewsets
from rest_framework.pagination import PageNumberPagination
from .models import Book
from .serializers import BookSerializer
class LargeResultsSetPagination(PageNumberPagination):
page_size = 100
page_size_query_param = 'page_size'
max_page_size = 1000
class BookViewSet(viewsets.ModelViewSet):
queryset = Book.objects.all()
serializer_class = BookSerializer
pagination_class = LargeResultsSetPagination
Pagination Types
DRF provides several pagination styles:
- PageNumberPagination: Uses page numbers (e.g.,
?page=2) - LimitOffsetPagination: Uses limit and offset parameters (e.g.,
?limit=10&offset=20) - CursorPagination: Uses cursors for more efficient pagination of large datasets
API Versioning
As your API evolves, you might need to support multiple versions simultaneously. DRF supports versioning out of the box:
# settings.py
REST_FRAMEWORK = {
'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.URLPathVersioning',
'DEFAULT_VERSION': 'v1',
'ALLOWED_VERSIONS': ['v1', 'v2'],
'VERSION_PARAM': 'version',
}
# urls.py
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from . import views_v1, views_v2
router_v1 = DefaultRouter()
router_v1.register(r'books', views_v1.BookViewSet)
router_v2 = DefaultRouter()
router_v2.register(r'books', views_v2.BookViewSet)
urlpatterns = [
path('api/v1/', include(router_v1.urls)),
path('api/v2/', include(router_v2.urls)),
]
Version-Specific ViewSets
# views_v1.py
from rest_framework import viewsets
from .models import Book
from .serializers_v1 import BookSerializer
class BookViewSet(viewsets.ModelViewSet):
queryset = Book.objects.all()
serializer_class = BookSerializer
# views_v2.py
from rest_framework import viewsets
from .models import Book
from .serializers_v2 import BookSerializer
class BookViewSet(viewsets.ModelViewSet):
queryset = Book.objects.all()
serializer_class = BookSerializer
Versioning Styles
DRF supports several versioning styles:
- URLPathVersioning: Version as part of the URL path (e.g.,
/api/v1/books/) - NamespaceVersioning: Version as a URL namespace
- QueryParameterVersioning: Version as a query parameter (e.g.,
/api/books/?version=v1) - AcceptHeaderVersioning: Version in the Accept header
- HostNameVersioning: Version as part of the hostname (e.g.,
v1.api.example.com)
Real-World Example: E-learning Platform API
Let's build a comprehensive API for an e-learning platform using ViewSets and Routers:
# models.py
from django.db import models
from django.contrib.auth.models import User
class Course(models.Model):
title = models.CharField(max_length=200)
description = models.TextField()
instructor = models.ForeignKey(User, on_delete=models.CASCADE, related_name='courses')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
is_published = models.BooleanField(default=False)
price = models.DecimalField(max_digits=6, decimal_places=2)
def __str__(self):
return self.title
class Lesson(models.Model):
course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='lessons')
title = models.CharField(max_length=200)
content = models.TextField()
order = models.PositiveIntegerField()
class Meta:
ordering = ['order']
def __str__(self):
return self.title
class Enrollment(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='enrollments')
course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='enrollments')
enrolled_at = models.DateTimeField(auto_now_add=True)
is_completed = models.BooleanField(default=False)
class Meta:
unique_together = ('user', 'course')
def __str__(self):
return f"{self.user.username} enrolled in {self.course.title}"
class Review(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='reviews')
course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='reviews')
rating = models.PositiveSmallIntegerField(choices=[(i, i) for i in range(1, 6)])
comment = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ('user', 'course')
def __str__(self):
return f"{self.rating} stars for {self.course.title} by {self.user.username}"
Serializers
# serializers.py
from rest_framework import serializers
from django.contrib.auth.models import User
from .models import Course, Lesson, Enrollment, Review
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ['id', 'username', 'email', 'first_name', 'last_name']
class LessonSerializer(serializers.ModelSerializer):
class Meta:
model = Lesson
fields = ['id', 'title', 'content', 'order']
class ReviewSerializer(serializers.ModelSerializer):
user = UserSerializer(read_only=True)
class Meta:
model = Review
fields = ['id', 'rating', 'comment', 'created_at', 'user']
read_only_fields = ['created_at', 'user']
class CourseListSerializer(serializers.ModelSerializer):
instructor = UserSerializer(read_only=True)
lesson_count = serializers.IntegerField(source='lessons.count', read_only=True)
average_rating = serializers.SerializerMethodField()
class Meta:
model = Course
fields = ['id', 'title', 'instructor', 'price', 'lesson_count', 'average_rating', 'is_published']
def get_average_rating(self, obj):
reviews = obj.reviews.all()
if not reviews:
return None
return round(sum(r.rating for r in reviews) / reviews.count(), 1)
class CourseDetailSerializer(serializers.ModelSerializer):
instructor = UserSerializer(read_only=True)
lessons = LessonSerializer(many=True, read_only=True)
reviews = ReviewSerializer(many=True, read_only=True)
enrollment_count = serializers.IntegerField(source='enrollments.count', read_only=True)
average_rating = serializers.SerializerMethodField()
class Meta:
model = Course
fields = [
'id', 'title', 'description', 'instructor', 'price',
'created_at', 'updated_at', 'is_published',
'lessons', 'reviews', 'enrollment_count', 'average_rating'
]
read_only_fields = ['created_at', 'updated_at']
def get_average_rating(self, obj):
reviews = obj.reviews.all()
if not reviews:
return None
return round(sum(r.rating for r in reviews) / reviews.count(), 1)
class EnrollmentSerializer(serializers.ModelSerializer):
user = UserSerializer(read_only=True)
course = CourseListSerializer(read_only=True)
class Meta:
model = Enrollment
fields = ['id', 'user', 'course', 'enrolled_at', 'is_completed']
read_only_fields = ['enrolled_at']
ViewSets
# views.py
from rest_framework import viewsets, mixins, permissions, status
from rest_framework.decorators import action
from rest_framework.response import Response
from django.db.models import Avg
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import SearchFilter, OrderingFilter
from django.shortcuts import get_object_or_404
from .models import Course, Lesson, Enrollment, Review
from .serializers import (
CourseListSerializer, CourseDetailSerializer, LessonSerializer,
EnrollmentSerializer, ReviewSerializer
)
from .permissions import IsInstructorOrReadOnly, IsEnrolledOrInstructor
class CourseViewSet(viewsets.ModelViewSet):
queryset = Course.objects.all()
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
filterset_fields = ['is_published', 'price']
search_fields = ['title', 'description', 'instructor__username']
ordering_fields = ['title', 'created_at', 'price']
ordering = ['-created_at']
permission_classes = [permissions.IsAuthenticatedOrReadOnly, IsInstructorOrReadOnly]
def get_serializer_class(self):
if self.action == 'list':
return CourseListSerializer
return CourseDetailSerializer
def perform_create(self, serializer):
serializer.save(instructor=self.request.user)
def get_queryset(self):
queryset = Course.objects.all()
# Non-instructors can only see published courses
if not self.request.user.is_authenticated or not self.request.user.courses.exists():
queryset = queryset.filter(is_published=True)
# Filter by minimum rating
min_rating = self.request.query_params.get('min_rating')
if min_rating:
queryset = queryset.annotate(avg_rating=Avg('reviews__rating'))
queryset = queryset.filter(avg_rating__gte=min_rating)
return queryset
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated])
def enroll(self, request, pk=None):
course = self.get_object()
user = request.user
# Check if already enrolled
if Enrollment.objects.filter(user=user, course=course).exists():
return Response(
{'detail': 'You are already enrolled in this course.'},
status=status.HTTP_400_BAD_REQUEST
)
# Create enrollment
enrollment = Enrollment.objects.create(user=user, course=course)
serializer = EnrollmentSerializer(enrollment)
return Response(serializer.data, status=status.HTTP_201_CREATED)
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated])
def review(self, request, pk=None):
course = self.get_object()
user = request.user
# Check if enrolled
if not Enrollment.objects.filter(user=user, course=course).exists():
return Response(
{'detail': 'You must be enrolled in this course to review it.'},
status=status.HTTP_403_FORBIDDEN
)
# Check if already reviewed
if Review.objects.filter(user=user, course=course).exists():
return Response(
{'detail': 'You have already reviewed this course.'},
status=status.HTTP_400_BAD_REQUEST
)
# Create review
serializer = ReviewSerializer(data=request.data)
if serializer.is_valid():
serializer.save(user=user, course=course)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@action(detail=True, methods=['get'])
def lessons(self, request, pk=None):
course = self.get_object()
# If not instructor, check if enrolled
if course.instructor != request.user and not Enrollment.objects.filter(
user=request.user, course=course
).exists():
return Response(
{'detail': 'You must be enrolled to view lessons.'},
status=status.HTTP_403_FORBIDDEN
)
lessons = course.lessons.all()
serializer = LessonSerializer(lessons, many=True)
return Response(serializer.data)
class LessonViewSet(viewsets.ModelViewSet):
queryset = Lesson.objects.all()
serializer_class = LessonSerializer
permission_classes = [permissions.IsAuthenticatedOrReadOnly, IsEnrolledOrInstructor]
def get_queryset(self):
# Get course_id from URL if present
course_id = self.request.query_params.get('course')
if course_id:
return Lesson.objects.filter(course_id=course_id)
return Lesson.objects.all()
def perform_create(self, serializer):
course_id = self.request.data.get('course')
course = get_object_or_404(Course, id=course_id)
# Ensure only the instructor can add lessons
if course.instructor != self.request.user:
self.permission_denied(
self.request,
message='You do not have permission to add lessons to this course.'
)
serializer.save(course=course)
class EnrollmentViewSet(viewsets.ReadOnlyModelViewSet, mixins.UpdateModelMixin):
serializer_class = EnrollmentSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
# Students see only their enrollments
if not self.request.user.courses.exists():
return Enrollment.objects.filter(user=self.request.user)
# Instructors see enrollments for their courses
return Enrollment.objects.filter(course__instructor=self.request.user)
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated])
def mark_completed(self, request, pk=None):
enrollment = self.get_object()
# Only the enrolled user can mark as completed
if enrollment.user != request.user:
return Response(
{'detail': 'You do not have permission to perform this action.'},
status=status.HTTP_403_FORBIDDEN
)
enrollment.is_completed = True
enrollment.save()
serializer = self.get_serializer(enrollment)
return Response(serializer.data)
Custom Permissions
# permissions.py
from rest_framework import permissions
class IsInstructorOrReadOnly(permissions.BasePermission):
"""
Custom permission to only allow instructors to edit courses.
"""
def has_object_permission(self, request, view, obj):
# Read permissions are allowed for any request
if request.method in permissions.SAFE_METHODS:
return True
# Write permissions are only allowed to the instructor
return obj.instructor == request.user
class IsEnrolledOrInstructor(permissions.BasePermission):
"""
Permission to only allow access to lessons if enrolled or instructor.
"""
def has_object_permission(self, request, view, obj):
# Instructor always has access
if obj.course.instructor == request.user:
return True
# Read access for enrolled users
if request.method in permissions.SAFE_METHODS:
return request.user.enrollments.filter(course=obj.course).exists()
# Write access only for the instructor
return False
Router Configuration
# urls.py
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from . import views
router = DefaultRouter()
router.register(r'courses', views.CourseViewSet)
router.register(r'lessons', views.LessonViewSet)
router.register(r'enrollments', views.EnrollmentViewSet, basename='enrollment')
urlpatterns = [
path('api/', include(router.urls)),
path('api-auth/', include('rest_framework.urls')),
]
This comprehensive example demonstrates:
- Multiple ViewSets with custom permissions
- Custom actions for enrollment and reviews
- Different serializers for list and detail views
- Filtering, searching, and ordering capabilities
- Custom queryset filtering based on user roles
- Nested resources (lessons within courses)
- Automatic URL generation with a router
Practice Activities
- Basic ViewSet: Convert the Book API from previous lectures to use a ModelViewSet and a DefaultRouter. Compare the code reduction and URL patterns generated.
- Custom Actions: Add custom actions to your BookViewSet, such as 'mark_as_read' and 'recommend', that perform different operations on books.
- Related ViewSets: Extend your API with Author and Genre ViewSets, connecting them appropriately with your Book ViewSet. Use a shared router for all ViewSets.
- Advanced Challenge: Implement a "library" system where users can borrow books. Create a LoanViewSet with appropriate permissions so users can only see their own loans, while librarians (a new user role) can see and manage all loans.
Key Takeaways
- ViewSets combine related views into a single class, reducing code duplication
- ModelViewSet provides a complete set of CRUD operations with minimal code
- Custom actions extend ViewSets with additional functionality
- Routers automatically generate URL patterns following REST conventions
- Permissions, filtering, and pagination can be easily integrated with ViewSets
- ViewSets support nested resources and different serializers for different actions
- The combination of ViewSets and Routers streamlines API development while maintaining consistency
Django REST Framework's ViewSets and Routers represent the highest level of abstraction for API development, allowing you to focus on business logic rather than repetitive CRUD operations and URL configuration. By leveraging these powerful tools, you can build robust, consistent APIs with significantly less code.
With the knowledge from all three lectures in this module, you now have a comprehensive understanding of Django REST Framework and the tools it provides for building modern web APIs. You can create serializers for data transformation, views for handling HTTP methods, and ViewSets with routers for streamlining the entire API development process.