Module 10: Django REST Framework — API Views & ViewSets
🎯 What You Will Learn in This Module
By the end of this tutorial, you will:
- Understand the difference between FBV APIs, Generic API Views, and ViewSets
- Build REST endpoints using
ListCreateAPIViewandRetrieveUpdateDestroyAPIView - Create a ModelViewSet with automatic CRUD operations
- Write custom permissions for object-level access control
- Implement filtering, search, ordering, and pagination
- Add rate throttling to prevent API abuse
- Create custom actions with the
@actiondecorator - Wire everything together with Django REST Framework's DefaultRouter
📖 1. Three Ways to Build API Views
Django REST Framework (DRF) gives you three approaches to building API endpoints:
| Approach | Best For | Code Amount |
|---|---|---|
| @api_view decorators | Simple, custom logic | Most control, most code |
| Generic API Views | Standard CRUD operations | Balanced |
| ViewSets + Routers | Full resource management | Least code, most convention |
💡 Rule of thumb: Use Generic API Views for simple APIs. Use ViewSets when you need full CRUD on a resource. Use
@api_viewfor custom endpoints that don't fit the standard pattern.
📝 2. Function-Based API Views
For simple or highly custom endpoints, DRF provides the @api_view decorator. It wraps a regular function so it can return Response objects instead of HttpResponse.
1# api/views.py 2 3from rest_framework.decorators import api_view, permission_classes 4from rest_framework.response import Response 5from rest_framework.permissions import IsAuthenticatedOrReadOnly 6from django.db.models import Count 7from blog.models import Category 8from .serializers import CategorySerializer 9 10 11@api_view(['GET', 'POST']) 12@permission_classes([IsAuthenticatedOrReadOnly]) 13def category_list(request): 14 """ 15 List all categories (GET) or create a new category (POST). 16 Only staff members can create categories. 17 """ 18 if request.method == 'GET': 19 # Annotate with post count for richer API responses 20 categories = Category.objects.annotate(post_count=Count('posts')) 21 serializer = CategorySerializer(categories, many=True) 22 return Response(serializer.data) 23 24 elif request.method == 'POST': 25 # Extra permission check: only staff can create categories 26 if not request.user.is_staff: 27 return Response( 28 {'error': 'Admin only'}, 29 status=403 30 ) 31 32 serializer = CategorySerializer(data=request.data) 33 if serializer.is_valid(): 34 serializer.save() 35 return Response(serializer.data, status=201) 36 return Response(serializer.errors, status=400)
Key concepts:
@api_view(['GET', 'POST']): Explicitly declare which HTTP methods this view accepts@permission_classes([...]): Control who can access this endpointResponse(serializer.data): DRF's smart response object that renders JSON by defaultmany=True: Tells the serializer to handle a list of objects instead of one
🏛️ 3. Generic API Views
For standard CRUD operations, DRF provides generic views that handle 90% of the logic for you.
ListCreateAPIView: GET (list) + POST (create)
1from rest_framework import generics 2from rest_framework.permissions import IsAuthenticated, IsAuthenticatedOrReadOnly 3from django_filters.rest_framework import DjangoFilterBackend 4from rest_framework import filters 5from blog.models import Post 6from .serializers import PostListSerializer, PostCreateUpdateSerializer 7from .permissions import IsAuthorOrReadOnly 8from .pagination import StandardResultsSetPagination 9 10 11class PostListCreateView(generics.ListCreateAPIView): 12 """ 13 GET: List all published posts (with pagination, filtering, search). 14 POST: Create a new post (authenticated users only). 15 """ 16 17 # Base queryset 18 queryset = Post.objects.filter(status='published') 19 20 # Pagination 21 pagination_class = StandardResultsSetPagination 22 23 # Filtering, Searching, Ordering 24 filter_backends = [ 25 DjangoFilterBackend, # Exact match filtering (?category=1) 26 filters.SearchFilter, # Text search (?search=django) 27 filters.OrderingFilter # Sorting (?ordering=-created_at) 28 ] 29 filterset_fields = ['category', 'author', 'status', 'is_featured'] 30 search_fields = ['title', 'content', 'excerpt'] 31 ordering_fields = ['created_at', 'view_count', 'published_at'] 32 ordering = ['-created_at'] # Default sort 33 34 def get_serializer_class(self): 35 """ 36 Use different serializers for reading (GET) vs writing (POST). 37 """ 38 if self.request.method == 'POST': 39 return PostCreateUpdateSerializer 40 return PostListSerializer 41 42 def get_permissions(self): 43 """ 44 Anyone can read, but only authenticated users can create. 45 """ 46 if self.request.method == 'POST': 47 return [IsAuthenticated()] 48 return [IsAuthenticatedOrReadOnly()] 49 50 def perform_create(self, serializer): 51 """ 52 Automatically set the author to the current user before saving. 53 """ 54 serializer.save(author=self.request.user)
What ListCreateAPIView does automatically:
- GET: Serializes the queryset and paginates the response
- POST: Validates the request data, creates the object, returns 201 Created
RetrieveUpdateDestroyAPIView: GET (detail) + PUT/PATCH (update) + DELETE
1class PostRetrieveUpdateDestroyView(generics.RetrieveUpdateDestroyAPIView): 2 """ 3 GET: Retrieve a single post by slug. 4 PUT/PATCH: Update the post (author or staff only). 5 DELETE: Remove the post (author or staff only). 6 """ 7 8 queryset = Post.objects.all() 9 lookup_field = 'slug' # Look up by slug instead of pk 10 permission_classes = [IsAuthorOrReadOnly] 11 12 def get_serializer_class(self): 13 """ 14 Use detail serializer for GET, write serializer for PUT/PATCH. 15 """ 16 if self.request.method in ['PUT', 'PATCH']: 17 return PostCreateUpdateSerializer 18 return PostDetailSerializer 19 20 def retrieve(self, request, *args, **kwargs): 21 """ 22 Override retrieve to increment view count on each GET request. 23 """ 24 instance = self.get_object() 25 26 # Efficient database-level increment 27 from django.db.models import F 28 Post.objects.filter(pk=instance.pk).update(view_count=F('view_count') + 1) 29 30 # Refresh instance to get updated view_count (optional) 31 instance.refresh_from_db() 32 33 serializer = self.get_serializer(instance) 34 return Response(serializer.data)
What RetrieveUpdateDestroyAPIView does automatically:
- GET: Returns the object or 404
- PUT/PATCH: Validates and updates the object
- DELETE: Removes the object and returns 204 No Content
🎛️ 4. ViewSets: The Ultimate Shortcut
A ViewSet is a class that combines list, create, retrieve, update, and destroy into a single class. You don't write separate URL patterns for each action — a Router generates them automatically.
1from rest_framework import viewsets 2from rest_framework.decorators import action 3from django.shortcuts import get_object_or_404 4from blog.models import Comment 5from .serializers import CommentSerializer 6 7 8class CommentViewSet(viewsets.ModelViewSet): 9 """ 10 Full CRUD for comments, plus a custom 'approve' action. 11 """ 12 serializer_class = CommentSerializer 13 permission_classes = [IsAuthenticatedOrReadOnly, IsAuthorOrReadOnly] 14 15 def get_queryset(self): 16 """ 17 Dynamically filter comments based on URL parameters. 18 """ 19 post_slug = self.kwargs.get('post_slug') 20 21 if post_slug: 22 # If accessed via /posts/<slug>/comments/ 23 return Comment.objects.filter(post__slug=post_slug, is_approved=True) 24 25 # Otherwise, return the current user's comments 26 return Comment.objects.filter(author=self.request.user) 27 28 def perform_create(self, serializer): 29 """ 30 Automatically associate the comment with the post and user. 31 """ 32 post = get_object_or_404(Post, slug=self.kwargs.get('post_slug')) 33 serializer.save(author=self.request.user, post=post) 34 35 # ------------------- CUSTOM ACTION ------------------- 36 37 @action(detail=True, methods=['post'], permission_classes=[IsAuthenticated]) 38 def approve(self, request, pk=None): 39 """ 40 Custom endpoint: POST /comments/<pk>/approve/ 41 Only staff members can approve comments. 42 """ 43 comment = self.get_object() 44 45 if not request.user.is_staff: 46 return Response( 47 {'error': 'Staff only'}, 48 status=403 49 ) 50 51 comment.is_approved = True 52 comment.save() 53 54 return Response({'status': 'comment approved'})
What ModelViewSet provides automatically:
| HTTP Method | Action | URL Pattern |
|---|---|---|
| GET | list() | /comments/ |
| POST | create() | /comments/ |
| GET | retrieve() | /comments/<pk>/ |
| PUT | update() | /comments/<pk>/ |
| PATCH | partial_update() | /comments/<pk>/ |
| DELETE | destroy() | /comments/<pk>/ |
What @action adds:
| Decorator | URL Generated |
|---|---|
@action(detail=True, methods=['post']) | /comments/<pk>/approve/ |
@action(detail=False, methods=['get']) | /comments/stats/ |
💡
detail=Truemeans the action operates on a single object (needs a primary key).detail=Falsemeans it operates on the collection.
🛡️ 5. Custom Permissions
DRF's built-in permissions are good, but real apps need object-level checks (e.g., only the author can edit their post).
1# api/permissions.py 2 3from rest_framework import permissions 4 5 6class IsAuthorOrReadOnly(permissions.BasePermission): 7 """ 8 Custom permission: only the author or staff can edit/delete an object. 9 Anyone can read (GET, HEAD, OPTIONS). 10 """ 11 12 def has_object_permission(self, request, view, obj): 13 # SAFE_METHODS = GET, HEAD, OPTIONS 14 if request.method in permissions.SAFE_METHODS: 15 return True 16 17 # Write permissions only for the author or staff 18 return obj.author == request.user or request.user.is_staff 19 20 21class IsAdminOrReadOnly(permissions.BasePermission): 22 """ 23 Only admins can write. Anyone can read. 24 """ 25 26 def has_permission(self, request, view): 27 if request.method in permissions.SAFE_METHODS: 28 return True 29 return request.user and request.user.is_staff 30 31 32class IsVerifiedUser(permissions.BasePermission): 33 """ 34 Only users who have verified their email can access. 35 """ 36 37 def has_permission(self, request, view): 38 return ( 39 request.user and 40 request.user.is_authenticated and 41 request.user.is_verified 42 )
Permission flow:
has_permission(): Called first. Checks if the user can access the view at all.has_object_permission(): Called after the object is retrieved. Checks if the user can perform the action on this specific object.
🔌 6. URL Routing with Routers
ViewSets don't use regular path() entries. They use Routers that auto-generate URL patterns.
1# api/urls.py 2 3from django.urls import path, include 4from rest_framework.routers import DefaultRouter 5from . import views 6 7# Create a router and register viewsets 8router = DefaultRouter() 9router.register(r'comments', views.CommentViewSet, basename='comment') 10 11# The router generates: 12# /comments/ -> list, create 13# /comments/<pk>/ -> retrieve, update, destroy 14# /comments/<pk>/approve/ -> custom action 15 16urlpatterns = [ 17 # Include all router-generated URLs 18 path('', include(router.urls)), 19 20 # Generic API View URLs 21 path('posts/', views.PostListCreateView.as_view(), name='api-posts'), 22 path('posts/<slug:slug>/', views.PostRetrieveUpdateDestroyView.as_view(), name='api-post-detail'), 23 24 # Nested comment creation under a post 25 path('posts/<slug:post_slug>/comments/', views.CommentViewSet.as_view({ 26 'get': 'list', 27 'post': 'create' 28 })), 29 30 # Function-based view 31 path('categories/', views.category_list, name='api-categories'), 32 33 # Registration endpoint 34 path('auth/register/', views.RegisterView.as_view(), name='api-register'), 35]
What DefaultRouter generates:
| URL | Method | Action |
|---|---|---|
/comments/ | GET | List all comments |
/comments/ | POST | Create new comment |
/comments/1/ | GET | Retrieve comment #1 |
/comments/1/ | PUT | Update comment #1 |
/comments/1/ | PATCH | Partial update comment #1 |
/comments/1/ | DELETE | Delete comment #1 |
/comments/1/approve/ | POST | Custom approve action |
💡 Tip: Visit
/api/in your browser when usingDefaultRouter. It provides a browsable API root page with links to all registered endpoints.
⚡ 7. Pagination, Filtering & Throttling
Custom Pagination Class
1# api/pagination.py 2 3from rest_framework.pagination import PageNumberPagination 4 5 6class StandardResultsSetPagination(PageNumberPagination): 7 """ 8 Standard pagination for API list views. 9 """ 10 page_size = 20 # Default items per page 11 page_size_query_param = 'page_size' # Allow client to override: ?page_size=50 12 max_page_size = 100 # Prevent abuse
Throttling (Rate Limiting)
Prevent API abuse by limiting how often users can make requests:
1# In settings.py (already configured in Module 2) 2 3REST_FRAMEWORK = { 4 'DEFAULT_THROTTLE_CLASSES': [ 5 'rest_framework.throttling.AnonRateThrottle', 6 'rest_framework.throttling.UserRateThrottle' 7 ], 8 'DEFAULT_THROTTLE_RATES': { 9 'anon': '100/day', # Anonymous users: 100 requests per day 10 'user': '1000/day' # Authenticated users: 1000 requests per day 11 } 12}
Per-view throttling:
1from rest_framework.throttling import AnonRateThrottle 2 3 4class RegisterView(generics.CreateAPIView): 5 serializer_class = UserRegistrationSerializer 6 permission_classes = [AllowAny] 7 throttle_classes = [AnonRateThrottle] # Limit registration attempts
🧪 8. Practice Task — Build Like/Unlike API Endpoint
Task Requirements
Create a custom @action on the PostViewSet that allows authenticated users to like and unlike posts. You need:
- A
Likemodel withuserandpostfields - A
POST /posts/<pk>/like/endpoint to like a post - A
POST /posts/<pk>/unlike/endpoint to unlike a post - A
like_countfield in the Post serializer
Solution
1# blog/models.py 2 3from django.contrib.auth import get_user_model 4 5User = get_user_model() 6 7 8class Like(models.Model): 9 """ 10 Tracks which users have liked which posts. 11 """ 12 user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='likes') 13 post = models.ForeignKey('Post', on_delete=models.CASCADE, related_name='likes') 14 created_at = models.DateTimeField(auto_now_add=True) 15 16 class Meta: 17 unique_together = ['user', 'post'] # Prevent duplicate likes 18 19 def __str__(self): 20 return f"{self.user.username} likes {self.post.title}"
1# api/serializers.py 2 3class PostDetailSerializer(serializers.ModelSerializer): 4 # ... existing fields ... 5 like_count = serializers.IntegerField(source='likes.count', read_only=True) 6 is_liked = serializers.SerializerMethodField() 7 8 class Meta: 9 model = Post 10 fields = [ 11 # ... existing fields ... 12 'like_count', 'is_liked' 13 ] 14 15 def get_is_liked(self, obj): 16 request = self.context.get('request') 17 if request and request.user.is_authenticated: 18 return obj.likes.filter(user=request.user).exists() 19 return False
1# api/views.py 2 3from rest_framework import viewsets, status 4from rest_framework.decorators import action 5from rest_framework.response import Response 6from rest_framework.permissions import IsAuthenticated 7from blog.models import Post, Like 8from .serializers import PostDetailSerializer 9 10 11class PostViewSet(viewsets.ModelViewSet): 12 """ 13 Full CRUD for posts, plus like/unlike actions. 14 """ 15 queryset = Post.objects.filter(status='published') 16 serializer_class = PostDetailSerializer 17 lookup_field = 'slug' 18 permission_classes = [IsAuthenticatedOrReadOnly, IsAuthorOrReadOnly] 19 20 @action(detail=True, methods=['post'], permission_classes=[IsAuthenticated]) 21 def like(self, request, slug=None): 22 """ 23 POST /posts/<slug>/like/ 24 """ 25 post = self.get_object() 26 user = request.user 27 28 # get_or_create returns (object, created_boolean) 29 like, created = Like.objects.get_or_create(user=user, post=post) 30 31 if not created: 32 return Response( 33 {'detail': 'You have already liked this post.'}, 34 status=status.HTTP_400_BAD_REQUEST 35 ) 36 37 return Response( 38 {'detail': 'Post liked.', 'like_count': post.likes.count()}, 39 status=status.HTTP_201_CREATED 40 ) 41 42 @action(detail=True, methods=['post'], permission_classes=[IsAuthenticated]) 43 def unlike(self, request, slug=None): 44 """ 45 POST /posts/<slug>/unlike/ 46 """ 47 post = self.get_object() 48 user = request.user 49 50 deleted, _ = Like.objects.filter(user=user, post=post).delete() 51 52 if deleted == 0: 53 return Response( 54 {'detail': 'You have not liked this post.'}, 55 status=status.HTTP_400_BAD_REQUEST 56 ) 57 58 return Response( 59 {'detail': 'Post unliked.', 'like_count': post.likes.count()}, 60 status=status.HTTP_200_OK 61 )
Register in the router:
1router.register(r'posts', views.PostViewSet, basename='post')
❌ Common Mistakes & How to Fix Them
| Mistake | Error / Symptom | Solution |
|---|---|---|
many=True missing | TypeError when serializing queryset | Add many=True to serializer |
Wrong lookup_field | 404 on detail pages | Ensure it matches URL parameter (<slug:slug>) |
permission_classes as tuple | TypeError | Use a list: [IsAuthenticated] |
@action without detail | TypeError | Always specify detail=True or detail=False |
perform_create not defined | Author not set automatically | Override perform_create to inject request.user |
get_queryset returns list | AttributeError | Must return a QuerySet |
basename missing in router | AssertionError | Provide basename if queryset is dynamic |
✅ Module 10 Summary
| Concept | Key Takeaway |
|---|---|
@api_view | Decorator for simple function-based API endpoints |
ListCreateAPIView | Handles GET (list) and POST (create) |
RetrieveUpdateDestroyAPIView | Handles GET (detail), PUT/PATCH, DELETE |
ModelViewSet | Combines all CRUD operations in one class |
@action | Adds custom endpoints to a ViewSet |
detail=True | Action needs an object PK |
detail=False | Action works on the collection |
DefaultRouter | Auto-generates URL patterns for ViewSets |
IsAuthorOrReadOnly | Custom object-level permission |
has_object_permission() | Checks permissions on a specific instance |
perform_create() | Hook to modify data before saving |
get_serializer_class() | Return different serializers per request method |
🚪 What's Next?
In Module 11, we will implement the complete Authentication & Authorization system. You'll learn to set up Token Authentication, JWT (JSON Web Tokens), custom login/logout views, and how to secure your API endpoints with the right authentication classes. We'll also cover password change functionality and the differences between session, token, and JWT authentication.
Before proceeding, make sure:
- Your
api/views.pycontains generic views and a ViewSet - Your
api/urls.pyusesDefaultRouterfor the ViewSet - Custom permissions (
IsAuthorOrReadOnly) are working - Filtering, search, and pagination are functional on the Post list endpoint
- You understand when to use
@api_viewvs Generic Views vs ViewSets - You completed the Like/Unlike practice task with the
@actiondecorator
Your API endpoints are live and secure. Ready to implement authentication? 🔐🚀