DRF ViewSets & Serializers
Writing ViewSets and serializers that produce clean OpenAPI schemas for type-safe client generation.
Core Pattern
Django-CFG uses GenericViewSet + @action pattern exclusively. Standard CRUD ViewSets (ModelViewSet, ReadOnlyModelViewSet) also work but custom actions are preferred for explicit API contracts.
ViewSet Requirements
Always set serializer_class
Every ViewSet must have a serializer_class defined. Without it, drf-spectacular cannot generate schemas.
# ✅ Correct
class DashboardViewSet(viewsets.GenericViewSet):
serializer_class = DashboardSerializer # Required!
@action(detail=False, methods=['get'])
def overview(self, request):
...
# ❌ Wrong - will cause generation errors
class DashboardViewSet(viewsets.GenericViewSet):
# Missing serializer_class!
@action(detail=False, methods=['get'])
def overview(self, request):
...Use get_serializer_class() for multiple serializers
When a ViewSet uses different serializers per action, implement get_serializer_class():
class UserViewSet(viewsets.GenericViewSet):
serializer_class = UserSerializer # Default (fallback)
def get_serializer_class(self):
if self.action == 'create':
return UserCreateSerializer
elif self.action == 'update':
return UserUpdateSerializer
return UserSerializerDecorator order matters
@extend_schema must come before @action:
# ✅ Correct order
@extend_schema(
summary="Get dashboard overview",
responses={200: DashboardSerializer},
tags=["dashboard"]
)
@action(detail=False, methods=['get'])
def overview(self, request):
...
# ❌ Wrong order - causes wrong tags, operationIds, serializer refs
@action(detail=False, methods=['get'])
@extend_schema(
summary="Get dashboard overview",
responses={200: DashboardSerializer},
tags=["dashboard"]
)
def overview(self, request):
...Serializer Best Practices
Use read_only_fields for detail serializers
To prevent TypeScript type splitting (Readable/Writable variants), make all fields read-only in response serializers:
class UserDetailSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ['id', 'email', 'name', 'created_at', 'is_active']
read_only_fields = fields # All read-only → single TS typeWithout this, the generator may create UserDetailReadable and UserDetailWritable types, and references to the base UserDetail type will break.
Separate read/write serializers
For endpoints with both input and output, use separate serializers:
# Response serializer (read-only)
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ['id', 'email', 'name', 'avatar', 'created_at']
read_only_fields = fields
# Request serializer (writable)
class UserCreateSerializer(serializers.Serializer):
email = serializers.EmailField()
name = serializers.CharField(max_length=100)
password = serializers.CharField(write_only=True)Explicit responses in @extend_schema
Always use dict format with status codes:
@extend_schema(
request=UserCreateSerializer,
responses={
201: UserSerializer,
400: ErrorSerializer,
}
)
@action(detail=False, methods=['post'])
def register(self, request):
...The dict format {200: Serializer} produces better TypeScript types than the bare responses=Serializer format.
Tag Grouping
Single ViewSet = Single tag
All actions in one ViewSet should use the same primary tag. Different tags cause endpoint splitting across multiple generated files.
# ✅ Correct - consistent tag
class DashboardViewSet(viewsets.GenericViewSet):
@extend_schema(tags=["dashboard"])
@action(detail=False, methods=['get'])
def overview(self, request): ...
@extend_schema(tags=["dashboard"])
@action(detail=False, methods=['get'])
def stats(self, request): ...# ❌ Wrong - different tags split into separate files
class DashboardViewSet(viewsets.GenericViewSet):
@extend_schema(tags=["Statistics"]) # → fetchers/statistics.ts
@action(...)
def stats(self, request): ...
@extend_schema(tags=["System Health"]) # → fetchers/system_health.ts
@action(...)
def health(self, request): ...Multiple tags for documentation
Use multiple tags where the first tag determines file grouping:
@extend_schema(
tags=["dashboard", "Statistics"], # Grouped under "dashboard"
responses={200: StatSerializer(many=True)}
)Tag naming
Use lowercase tags with underscores:
dashboard— all endpoints grouped in one fileuser_management— clear namingAdmin Panel - Commands— spaces and dashes work but create verbose file names
Complete Example
from drf_spectacular.utils import extend_schema, extend_schema_view
from rest_framework import viewsets, permissions, status
from rest_framework.decorators import action
from rest_framework.response import Response
@extend_schema_view(
list=extend_schema(
summary="List users",
tags=["users"],
),
retrieve=extend_schema(
summary="Get user details",
tags=["users"],
),
)
class UserViewSet(viewsets.ReadOnlyModelViewSet):
queryset = User.objects.all()
serializer_class = UserSerializer
permission_classes = [permissions.IsAuthenticated]
def get_serializer_class(self):
if self.action == 'list':
return UserListSerializer
return UserSerializer
@extend_schema(
summary="Get current user profile",
responses={200: UserSerializer},
tags=["users"],
)
@action(detail=False, methods=['get'], url_path='me')
def me(self, request):
serializer = UserSerializer(request.user)
return Response(serializer.data)
@extend_schema(
summary="Update profile",
request=UserUpdateSerializer,
responses={200: UserSerializer},
tags=["users"],
)
@action(detail=False, methods=['patch'], url_path='me')
def update_me(self, request):
serializer = UserUpdateSerializer(
request.user, data=request.data, partial=True
)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(UserSerializer(request.user).data)Checklist
Before running client generation, verify:
- Every ViewSet has
serializer_classdefined -
@extend_schemacomes before@actiondecorators - Responses use dict format:
{200: Serializer} - Detail serializers use
read_only_fields = fields - All actions in a ViewSet use the same primary tag
- Read/write operations use separate serializers
Next Steps
- @action Request Body Validation — Strict rule that blocks client generation when detail-actions silently inherit the ViewSet’s default request body
- Array Responses — Returning arrays from
@actionmethods - Nested Serializers — Handling
allOfand nested types - Pagination — Pagination patterns for ViewSet actions