Skip to main content
2024-10-2225 min read
Web Development

The Ultimate DRF Spectacular Guide: Every Feature in One ViewSet

Introduction

If you've ever struggled to find comprehensive examples of what drf-spectacular can do, you're not alone. The documentation, while good, lacks a single comprehensive example that showcases all features. This blog post aims to fix that by presenting a single, exhaustively annotated ViewSet that demonstrates every feature drf-spectacular offers.

Prerequisites

First, let's set up our environment:
bash
1pip install djangorestframework drf-spectacular django-filter
Add to your settings.py:
python
1INSTALLED_APPS = [
2 # ...
3 'rest_framework',
4 'drf_spectacular',
5 'django_filters',
6]
7
8REST_FRAMEWORK = {
9 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
10}
11
12SPECTACULAR_SETTINGS = {
13 'TITLE': 'Ultimate API Example',
14 'DESCRIPTION': 'Comprehensive example of all drf-spectacular features',
15 'VERSION': '1.0.0',
16 'SERVE_INCLUDE_SCHEMA': False,
17 'COMPONENT_SPLIT_REQUEST': True,
18 'ENUM_NAME_OVERRIDES': {
19 'StatusEnum': 'myapp.models.Product.Status',
20 },
21}

The Ultimate ViewSet

Here's our comprehensive ViewSet that demonstrates every drf-spectacular feature:
python
1from django.db import models
2from django.contrib.auth.models import User
3from rest_framework import viewsets, serializers, status, filters
4from rest_framework.decorators import action
5from rest_framework.response import Response
6from rest_framework.parsers import MultiPartParser, JSONParser
7from rest_framework.permissions import IsAuthenticated
8from django_filters import rest_framework as django_filters
9from drf_spectacular.utils import (
10 extend_schema,
11 extend_schema_view,
12 OpenApiParameter,
13 OpenApiExample,
14 OpenApiResponse,
15 PolymorphicProxySerializer,
16 inline_serializer,
17 extend_schema_serializer,
18 extend_schema_field,
19 OpenApiTypes,
20)
21from drf_spectacular.types import OpenApiTypes
22from typing import Optional, List, Union
23import uuid
24from datetime import datetime
25from decimal import Decimal
26
27
28# Models
29class Product(models.Model):
30 class Status(models.TextChoices):
31 DRAFT = 'DRAFT', 'Draft'
32 PUBLISHED = 'PUBLISHED', 'Published'
33 ARCHIVED = 'ARCHIVED', 'Archived'
34
35 class Category(models.TextChoices):
36 ELECTRONICS = 'ELECTRONICS', 'Electronics'
37 CLOTHING = 'CLOTHING', 'Clothing'
38 FOOD = 'FOOD', 'Food'
39 BOOKS = 'BOOKS', 'Books'
40
41 id = models.UUIDField(primary_key=True, default=uuid.uuid4)
42 name = models.CharField(max_length=200)
43 description = models.TextField()
44 price = models.DecimalField(max_digits=10, decimal_places=2)
45 status = models.CharField(max_length=20, choices=Status.choices, default=Status.DRAFT)
46 category = models.CharField(max_length=20, choices=Category.choices)
47 tags = models.JSONField(default=list)
48 metadata = models.JSONField(default=dict)
49 created_by = models.ForeignKey(User, on_delete=models.CASCADE, related_name='products')
50 created_at = models.DateTimeField(auto_now_add=True)
51 updated_at = models.DateTimeField(auto_now=True)
52 image = models.ImageField(upload_to='products/', null=True, blank=True)
53
54 class Meta:
55 ordering = ['-created_at']
56
57
58# Serializers with extensive annotations
59@extend_schema_serializer(
60 examples=[
61 OpenApiExample(
62 'Valid product example',
63 summary='Example of a valid product',
64 description='This shows all fields populated correctly',
65 value={
66 'id': '550e8400-e29b-41d4-a716-446655440000',
67 'name': 'MacBook Pro',
68 'description': 'High-performance laptop',
69 'price': '2499.99',
70 'status': 'PUBLISHED',
71 'category': 'ELECTRONICS',
72 'tags': ['laptop', 'apple', 'professional'],
73 'metadata': {'warranty': '2 years', 'color': 'silver'},
74 'created_by': 1,
75 'created_at': '2024-01-15T10:00:00Z',
76 'updated_at': '2024-01-15T10:00:00Z'
77 },
78 request_only=False,
79 response_only=False,
80 ),
81 OpenApiExample(
82 'Minimal product example',
83 summary='Minimal required fields',
84 description='Only required fields populated',
85 value={
86 'name': 'Basic Product',
87 'description': 'A simple product',
88 'price': '9.99',
89 'category': 'BOOKS',
90 'created_by': 1
91 },
92 request_only=True,
93 ),
94 ],
95 deprecate_fields=['metadata'], # Mark fields as deprecated
96)
97class ProductSerializer(serializers.ModelSerializer):
98 # Custom field with schema annotation
99 @extend_schema_field(OpenApiTypes.STR)
100 def get_display_name(self, obj):
101 return f"{obj.name} ({obj.category})"
102
103 display_name = serializers.SerializerMethodField()
104
105 # Field with custom schema
106 tags = serializers.ListField(
107 child=serializers.CharField(),
108 help_text="List of tags associated with the product",
109 required=False,
110 default=list
111 )
112
113 # Annotated field with constraints
114 price = serializers.DecimalField(
115 max_digits=10,
116 decimal_places=2,
117 min_value=Decimal('0.01'),
118 max_value=Decimal('999999.99'),
119 help_text="Product price in USD"
120 )
121
122 class Meta:
123 model = Product
124 fields = '__all__'
125 read_only_fields = ['id', 'created_at', 'updated_at']
126 extra_kwargs = {
127 'description': {
128 'help_text': 'Detailed description of the product',
129 'style': {'base_template': 'textarea.html'}
130 },
131 'metadata': {
132 'help_text': '⚠️ DEPRECATED: Use tags instead. Arbitrary JSON metadata',
133 }
134 }
135
136
137# Filter class
138class ProductFilter(django_filters.FilterSet):
139 name = django_filters.CharFilter(lookup_expr='icontains')
140 price_min = django_filters.NumberFilter(field_name='price', lookup_expr='gte')
141 price_max = django_filters.NumberFilter(field_name='price', lookup_expr='lte')
142 created_after = django_filters.DateTimeFilter(field_name='created_at', lookup_expr='gte')
143 created_before = django_filters.DateTimeFilter(field_name='created_at', lookup_expr='lte')
144
145 class Meta:
146 model = Product
147 fields = ['status', 'category', 'created_by']
148
149
150# Response serializers for different scenarios
151class ProductListSerializer(serializers.ModelSerializer):
152 """Lightweight serializer for list views"""
153 class Meta:
154 model = Product
155 fields = ['id', 'name', 'price', 'status', 'category', 'created_at']
156
157
158class ErrorResponseSerializer(serializers.Serializer):
159 """Standard error response"""
160 error = serializers.CharField()
161 code = serializers.CharField()
162 details = serializers.DictField(required=False)
163
164
165class BulkOperationResultSerializer(serializers.Serializer):
166 """Response for bulk operations"""
167 successful = serializers.IntegerField()
168 failed = serializers.IntegerField()
169 errors = serializers.ListField(
170 child=serializers.DictField(),
171 required=False
172 )
173
174
175# The Ultimate ViewSet
176@extend_schema_view(
177 list=extend_schema(
178 summary="List all products",
179 description="""
180 Retrieve a paginated list of products with optional filtering.
181
182 ## Filtering
183 You can filter products using query parameters:
184 - `status`: Filter by product status
185 - `category`: Filter by product category
186 - `price_min`, `price_max`: Filter by price range
187 - `created_after`, `created_before`: Filter by creation date
188
189 ## Ordering
190 Use the `ordering` parameter to sort results:
191 - `price`: Sort by price (ascending)
192 - `-price`: Sort by price (descending)
193 - `created_at`: Sort by creation date
194
195 ## Search
196 Use the `search` parameter to search in name and description fields.
197 """,
198 tags=['Products', 'Catalog'],
199 operation_id="listProducts",
200 parameters=[
201 OpenApiParameter(
202 name='expand',
203 type=OpenApiTypes.STR,
204 location=OpenApiParameter.QUERY,
205 description='Comma-separated list of fields to expand',
206 examples=[
207 OpenApiExample('Basic expansion', value='created_by'),
208 OpenApiExample('Multiple expansions', value='created_by,category_details'),
209 ],
210 ),
211 OpenApiParameter(
212 name='fields',
213 type=OpenApiTypes.STR,
214 location=OpenApiParameter.QUERY,
215 description='Comma-separated list of fields to include in response',
216 examples=[
217 OpenApiExample('Minimal fields', value='id,name,price'),
218 OpenApiExample('Extended fields', value='id,name,price,description,status'),
219 ],
220 ),
221 ],
222 responses={
223 200: ProductListSerializer(many=True),
224 401: OpenApiResponse(
225 response=ErrorResponseSerializer,
226 description="Authentication credentials were not provided"
227 ),
228 },
229 ),
230 create=extend_schema(
231 summary="Create a new product",
232 description="Create a new product in the catalog",
233 tags=['Products'],
234 request=ProductSerializer,
235 responses={
236 201: ProductSerializer,
237 400: ErrorResponseSerializer,
238 401: ErrorResponseSerializer,
239 },
240 examples=[
241 OpenApiExample(
242 'Create product request',
243 value={
244 'name': 'New Product',
245 'description': 'Product description',
246 'price': '29.99',
247 'category': 'ELECTRONICS',
248 'status': 'DRAFT',
249 'tags': ['new', 'featured'],
250 },
251 request_only=True,
252 ),
253 ],
254 ),
255 retrieve=extend_schema(
256 summary="Get product details",
257 description="Retrieve detailed information about a specific product",
258 tags=['Products'],
259 responses={
260 200: ProductSerializer,
261 404: ErrorResponseSerializer,
262 },
263 ),
264 update=extend_schema(
265 summary="Update product",
266 description="Update all fields of a product",
267 tags=['Products'],
268 request=ProductSerializer,
269 responses={
270 200: ProductSerializer,
271 400: ErrorResponseSerializer,
272 404: ErrorResponseSerializer,
273 },
274 ),
275 partial_update=extend_schema(
276 summary="Partially update product",
277 description="Update specific fields of a product",
278 tags=['Products'],
279 request=ProductSerializer,
280 responses={
281 200: ProductSerializer,
282 400: ErrorResponseSerializer,
283 404: ErrorResponseSerializer,
284 },
285 ),
286 destroy=extend_schema(
287 summary="Delete product",
288 description="Remove a product from the catalog",
289 tags=['Products'],
290 responses={
291 204: None,
292 404: ErrorResponseSerializer,
293 },
294 ),
295)
296class ProductViewSet(viewsets.ModelViewSet):
297 queryset = Product.objects.all()
298 serializer_class = ProductSerializer
299 permission_classes = [IsAuthenticated]
300 filter_backends = [
301 django_filters.DjangoFilterBackend,
302 filters.SearchFilter,
303 filters.OrderingFilter
304 ]
305 filterset_class = ProductFilter
306 search_fields = ['name', 'description']
307 ordering_fields = ['price', 'created_at', 'name']
308 ordering = ['-created_at']
309 lookup_field = 'id'
310
311 def get_serializer_class(self):
312 if self.action == 'list':
313 return ProductListSerializer
314 return ProductSerializer
315
316 @extend_schema(
317 summary="Bulk create products",
318 description="Create multiple products in a single request",
319 tags=['Products', 'Bulk Operations'],
320 request=inline_serializer(
321 name='BulkCreateRequest',
322 fields={
323 'products': ProductSerializer(many=True),
324 'validate_all': serializers.BooleanField(
325 default=True,
326 help_text="If true, validate all products before creating any"
327 ),
328 }
329 ),
330 responses={
331 201: inline_serializer(
332 name='BulkCreateResponse',
333 fields={
334 'created': ProductSerializer(many=True),
335 'failed': serializers.ListField(
336 child=serializers.DictField(),
337 help_text="List of products that failed validation"
338 ),
339 }
340 ),
341 400: ErrorResponseSerializer,
342 },
343 examples=[
344 OpenApiExample(
345 'Bulk create request',
346 value={
347 'products': [
348 {
349 'name': 'Product 1',
350 'description': 'First product',
351 'price': '10.99',
352 'category': 'BOOKS',
353 'created_by': 1
354 },
355 {
356 'name': 'Product 2',
357 'description': 'Second product',
358 'price': '20.99',
359 'category': 'ELECTRONICS',
360 'created_by': 1
361 }
362 ],
363 'validate_all': True
364 },
365 request_only=True,
366 ),
367 ],
368 )
369 @action(detail=False, methods=['post'], url_path='bulk-create')
370 def bulk_create(self, request):
371 """Create multiple products at once"""
372 # Implementation here
373 return Response({'created': [], 'failed': []}, status=status.HTTP_201_CREATED)
374
375 @extend_schema(
376 summary="Change product status",
377 description="Update the status of a product with validation",
378 tags=['Products', 'Status Management'],
379 request=inline_serializer(
380 name='StatusChangeRequest',
381 fields={
382 'status': serializers.ChoiceField(
383 choices=Product.Status.choices,
384 help_text="New status for the product"
385 ),
386 'reason': serializers.CharField(
387 required=False,
388 help_text="Optional reason for status change"
389 ),
390 }
391 ),
392 responses={
393 200: ProductSerializer,
394 400: inline_serializer(
395 name='StatusChangeError',
396 fields={
397 'error': serializers.CharField(),
398 'current_status': serializers.CharField(),
399 'requested_status': serializers.CharField(),
400 'allowed_transitions': serializers.ListField(
401 child=serializers.CharField()
402 ),
403 }
404 ),
405 404: ErrorResponseSerializer,
406 },
407 parameters=[
408 OpenApiParameter(
409 name='notify',
410 type=OpenApiTypes.BOOL,
411 location=OpenApiParameter.QUERY,
412 description='Send notification about status change',
413 default=False,
414 ),
415 ],
416 )
417 @action(detail=True, methods=['post'], url_path='change-status')
418 def change_status(self, request, id=None):
419 """Change product status with business rules validation"""
420 product = self.get_object()
421 # Implementation here
422 return Response(ProductSerializer(product).data)
423
424 @extend_schema(
425 summary="Upload product image",
426 description="Upload or update the product image",
427 tags=['Products', 'Media'],
428 request={
429 'multipart/form-data': inline_serializer(
430 name='ImageUploadRequest',
431 fields={
432 'image': serializers.ImageField(
433 help_text="Product image file (JPEG, PNG, max 5MB)"
434 ),
435 'alt_text': serializers.CharField(
436 required=False,
437 help_text="Alternative text for accessibility"
438 ),
439 }
440 )
441 },
442 responses={
443 200: inline_serializer(
444 name='ImageUploadResponse',
445 fields={
446 'image_url': serializers.URLField(),
447 'thumbnail_url': serializers.URLField(),
448 'size': serializers.IntegerField(help_text="File size in bytes"),
449 'dimensions': serializers.DictField(
450 help_text="Image dimensions",
451 child=serializers.IntegerField()
452 ),
453 }
454 ),
455 400: ErrorResponseSerializer,
456 413: OpenApiResponse(
457 description="File too large",
458 response=ErrorResponseSerializer,
459 ),
460 },
461 )
462 @action(
463 detail=True,
464 methods=['post'],
465 url_path='upload-image',
466 parser_classes=[MultiPartParser]
467 )
468 def upload_image(self, request, id=None):
469 """Upload product image with automatic thumbnail generation"""
470 # Implementation here
471 return Response({
472 'image_url': 'https://example.com/image.jpg',
473 'thumbnail_url': 'https://example.com/thumb.jpg',
474 'size': 1024000,
475 'dimensions': {'width': 1920, 'height': 1080}
476 })
477
478 @extend_schema(
479 summary="Get product statistics",
480 description="Retrieve aggregated statistics for products",
481 tags=['Products', 'Analytics'],
482 parameters=[
483 OpenApiParameter(
484 name='date_from',
485 type=OpenApiTypes.DATE,
486 location=OpenApiParameter.QUERY,
487 description='Start date for statistics',
488 required=False,
489 ),
490 OpenApiParameter(
491 name='date_to',
492 type=OpenApiTypes.DATE,
493 location=OpenApiParameter.QUERY,
494 description='End date for statistics',
495 required=False,
496 ),
497 OpenApiParameter(
498 name='group_by',
499 type=OpenApiTypes.STR,
500 location=OpenApiParameter.QUERY,
501 enum=['category', 'status', 'created_by'],
502 description='Field to group statistics by',
503 default='category',
504 ),
505 ],
506 responses={
507 200: inline_serializer(
508 name='ProductStatistics',
509 fields={
510 'total_products': serializers.IntegerField(),
511 'total_value': serializers.DecimalField(max_digits=12, decimal_places=2),
512 'average_price': serializers.DecimalField(max_digits=10, decimal_places=2),
513 'by_category': serializers.DictField(
514 child=serializers.IntegerField(),
515 help_text="Product count by category"
516 ),
517 'by_status': serializers.DictField(
518 child=serializers.IntegerField(),
519 help_text="Product count by status"
520 ),
521 'price_range': serializers.DictField(
522 help_text="Min and max prices",
523 child=serializers.DecimalField(max_digits=10, decimal_places=2)
524 ),
525 'recent_products': ProductListSerializer(many=True),
526 }
527 ),
528 },
529 )
530 @action(detail=False, methods=['get'], url_path='statistics')
531 def statistics(self, request):
532 """Get comprehensive product statistics"""
533 # Implementation here
534 return Response({
535 'total_products': 150,
536 'total_value': '374999.50',
537 'average_price': '2499.99',
538 'by_category': {
539 'ELECTRONICS': 45,
540 'CLOTHING': 30,
541 'FOOD': 25,
542 'BOOKS': 50
543 },
544 'by_status': {
545 'DRAFT': 20,
546 'PUBLISHED': 120,
547 'ARCHIVED': 10
548 },
549 'price_range': {
550 'min': '9.99',
551 'max': '9999.99'
552 },
553 'recent_products': []
554 })
555
556 @extend_schema(
557 summary="Export products",
558 description="Export products in various formats",
559 tags=['Products', 'Export'],
560 parameters=[
561 OpenApiParameter(
562 name='format',
563 type=OpenApiTypes.STR,
564 location=OpenApiParameter.QUERY,
565 enum=['csv', 'json', 'xlsx'],
566 description='Export format',
567 required=True,
568 ),
569 OpenApiParameter(
570 name='fields',
571 type={'type': 'array', 'items': {'type': 'string'}},
572 location=OpenApiParameter.QUERY,
573 description='Fields to include in export',
574 explode=False,
575 style='form',
576 ),
577 ],
578 responses={
579 200: OpenApiResponse(
580 response=OpenApiTypes.BINARY,
581 description="Exported file",
582 ),
583 202: inline_serializer(
584 name='ExportJobCreated',
585 fields={
586 'job_id': serializers.UUIDField(),
587 'status': serializers.CharField(),
588 'estimated_time': serializers.IntegerField(
589 help_text="Estimated completion time in seconds"
590 ),
591 }
592 ),
593 },
594 )
595 @action(detail=False, methods=['get'], url_path='export')
596 def export(self, request):
597 """Export products with support for large datasets"""
598 # Implementation here
599 return Response(
600 b'file_content',
601 headers={
602 'Content-Disposition': 'attachment; filename="products.csv"',
603 'Content-Type': 'text/csv',
604 }
605 )
606
607 @extend_schema(
608 summary="Product recommendations",
609 description="Get AI-powered product recommendations",
610 tags=['Products', 'AI', 'Recommendations'],
611 request=inline_serializer(
612 name='RecommendationRequest',
613 fields={
614 'user_preferences': serializers.DictField(
615 required=False,
616 help_text="User preference data for personalization"
617 ),
618 'context': serializers.ChoiceField(
619 choices=['similar', 'complementary', 'trending', 'personalized'],
620 default='similar',
621 help_text="Type of recommendations to generate"
622 ),
623 'limit': serializers.IntegerField(
624 default=5,
625 min_value=1,
626 max_value=20,
627 help_text="Number of recommendations to return"
628 ),
629 }
630 ),
631 responses={
632 200: inline_serializer(
633 name='RecommendationResponse',
634 fields={
635 'recommendations': ProductListSerializer(many=True),
636 'confidence_scores': serializers.ListField(
637 child=serializers.FloatField(min_value=0, max_value=1),
638 help_text="Confidence score for each recommendation"
639 ),
640 'reasoning': serializers.ListField(
641 child=serializers.CharField(),
642 help_text="Explanation for each recommendation"
643 ),
644 }
645 ),
646 },
647 deprecated=False, # Can mark endpoints as deprecated
648 )
649 @action(detail=True, methods=['post'], url_path='recommendations')
650 def recommendations(self, request, id=None):
651 """Get AI-powered product recommendations"""
652 # Implementation here
653 return Response({
654 'recommendations': [],
655 'confidence_scores': [0.95, 0.87, 0.82, 0.79, 0.75],
656 'reasoning': [
657 "Frequently bought together",
658 "Similar price range and category",
659 "Trending in your area",
660 "Based on your purchase history",
661 "Popular among similar users"
662 ]
663 })
664
665 @extend_schema(
666 summary="Polymorphic search",
667 description="Search across multiple entity types",
668 tags=['Products', 'Search'],
669 parameters=[
670 OpenApiParameter(
671 name='q',
672 type=OpenApiTypes.STR,
673 location=OpenApiParameter.QUERY,
674 description='Search query',
675 required=True,
676 ),
677 OpenApiParameter(
678 name='types',
679 type={'type': 'array', 'items': {'type': 'string', 'enum': ['product', 'category', 'tag']}},
680 location=OpenApiParameter.QUERY,
681 description='Entity types to search',
682 explode=True,
683 style='form',
684 ),
685 ],
686 responses={
687 200: PolymorphicProxySerializer(
688 component_name='SearchResult',
689 resource_type_field_name='result_type',
690 serializers={
691 'product': ProductListSerializer,
692 'category': inline_serializer(
693 name='CategoryResult',
694 fields={
695 'name': serializers.CharField(),
696 'product_count': serializers.IntegerField(),
697 }
698 ),
699 'tag': inline_serializer(
700 name='TagResult',
701 fields={
702 'tag': serializers.CharField(),
703 'usage_count': serializers.IntegerField(),
704 }
705 ),
706 },
707 many=True
708 ),
709 },
710 )
711 @action(detail=False, methods=['get'], url_path='search')
712 def polymorphic_search(self, request):
713 """Search across multiple entity types with polymorphic results"""
714 # Implementation here
715 return Response([
716 {
717 'result_type': 'product',
718 'id': 'uuid-here',
719 'name': 'Found Product',
720 'price': '99.99'
721 },
722 {
723 'result_type': 'category',
724 'name': 'Electronics',
725 'product_count': 45
726 },
727 {
728 'result_type': 'tag',
729 'tag': 'bestseller',
730 'usage_count': 23
731 }
732 ])
733
734 @extend_schema(
735 summary="Webhook subscriptions",
736 description="Manage webhook subscriptions for product events",
737 tags=['Products', 'Webhooks'],
738 request=inline_serializer(
739 name='WebhookSubscriptionRequest',
740 fields={
741 'url': serializers.URLField(
742 help_text="HTTPS endpoint to receive webhook events"
743 ),
744 'events': serializers.ListField(
745 child=serializers.ChoiceField(
746 choices=[
747 'product.created',
748 'product.updated',
749 'product.deleted',
750 'product.status_changed',
751 'product.price_changed'
752 ]
753 ),
754 help_text="Events to subscribe to"
755 ),
756 'secret': serializers.CharField(
757 write_only=True,
758 help_text="Shared secret for webhook signature validation"
759 ),
760 'active': serializers.BooleanField(
761 default=True,
762 help_text="Whether the webhook is active"
763 ),
764 }
765 ),
766 responses={
767 201: inline_serializer(
768 name='WebhookSubscriptionResponse',
769 fields={
770 'id': serializers.UUIDField(),
771 'url': serializers.URLField(),
772 'events': serializers.ListField(child=serializers.CharField()),
773 'active': serializers.BooleanField(),
774 'created_at': serializers.DateTimeField(),
775 'last_triggered': serializers.DateTimeField(required=False),
776 }
777 ),
778 },
779 )
780 @action(detail=False, methods=['post'], url_path='webhooks/subscribe')
781 def webhook_subscribe(self, request):
782 """Subscribe to product event webhooks"""
783 # Implementation here
784 return Response({
785 'id': str(uuid.uuid4()),
786 'url': request.data.get('url'),
787 'events': request.data.get('events', []),
788 'active': True,
789 'created_at': datetime.now().isoformat(),
790 }, status=status.HTTP_201_CREATED)

Additional Schema Customizations

Custom Operation IDs

python
1@extend_schema(operation_id="getProductById")
2@action(detail=True)
3def custom_retrieve(self, request, pk=None):
4 pass

Excluding Endpoints from Schema

python
1@extend_schema(exclude=True)
2@action(detail=False)
3def internal_endpoint(self, request):
4 pass

Custom Tags and Grouping

python
1@extend_schema(tags=['Public API', 'v2'])
2@action(detail=False)
3def public_endpoint(self, request):
4 pass

Versioning Support

python
1@extend_schema(
2 versions=['v1', 'v2'], # Available in specific API versions
3 summary="Version-specific endpoint"
4)
5def versioned_endpoint(self, request):
6 pass

Security Schemes

python
1@extend_schema(
2 auth=[
3 'BasicAuth',
4 'BearerAuth',
5 {'ApiKeyAuth': []},
6 {}, # No auth required
7 ]
8)
9def multi_auth_endpoint(self, request):
10 pass

Response Headers

python
1@extend_schema(
2 responses={
3 200: OpenApiResponse(
4 response=ProductSerializer,
5 description="Success",
6 headers={
7 'X-Rate-Limit': {
8 'description': 'Request limit per hour',
9 'schema': {'type': 'integer'}
10 },
11 'X-Rate-Limit-Remaining': {
12 'description': 'Remaining requests',
13 'schema': {'type': 'integer'}
14 }
15 }
16 )
17 }
18)
19def rate_limited_endpoint(self, request):
20 pass

Callbacks (Webhooks)

python
1@extend_schema(
2 callbacks={
3 'statusChange': {
4 '{$request.body#/callback_url}': {
5 'post': {
6 'requestBody': {
7 'required': True,
8 'content': {
9 'application/json': {
10 'schema': {
11 'type': 'object',
12 'properties': {
13 'event': {'type': 'string'},
14 'product_id': {'type': 'string'},
15 'old_status': {'type': 'string'},
16 'new_status': {'type': 'string'},
17 'timestamp': {'type': 'string', 'format': 'date-time'}
18 }
19 }
20 }
21 }
22 },
23 'responses': {
24 '200': {'description': 'Webhook received'}
25 }
26 }
27 }
28 }
29 }
30)
31def trigger_with_callback(self, request):
32 pass

Global Schema Extensions

In your settings.py:
python
1SPECTACULAR_SETTINGS = {
2 # ... other settings ...
3 'PREPROCESSING_HOOKS': ['myapp.schema.preprocessing_hook'],
4 'POSTPROCESSING_HOOKS': ['myapp.schema.postprocessing_hook'],
5 'SERVE_PERMISSIONS': ['rest_framework.permissions.IsAuthenticated'],
6 'SERVERS': [
7 {'url': 'https://api.example.com', 'description': 'Production server'},
8 {'url': 'https://staging-api.example.com', 'description': 'Staging server'},
9 ],
10 'EXTERNAL_DOCS': {
11 'description': 'Extended API Documentation',
12 'url': 'https://docs.example.com',
13 },
14 'TAGS': [
15 {'name': 'Products', 'description': 'Product management endpoints'},
16 {'name': 'Analytics', 'description': 'Analytics and reporting'},
17 {'name': 'AI', 'description': 'AI-powered features'},
18 ],
19}

Testing Your Schema

python
1# In your tests
2from drf_spectacular.validation import validate_schema
3
4def test_schema_validation(self):
5 """Ensure OpenAPI schema is valid"""
6 validate_schema(spectacular_settings.DEFAULT_GENERATOR_CLASS().get_schema())

Generating the Schema

python
1# urls.py
2from drf_spectacular.views import (
3 SpectacularAPIView,
4 SpectacularRedocView,
5 SpectacularSwaggerView
6)
7
8urlpatterns = [
9 path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
10 path('api/docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),
11 path('api/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'),
12]

Conclusion

This comprehensive example demonstrates virtually every feature that drf-spectacular offers:
💡
Feature Checklist:
  • ✅ Detailed endpoint documentation with descriptions, summaries, and tags
  • ✅ Request/response examples with multiple scenarios
  • ✅ Parameter documentation with types, constraints, and examples
  • ✅ Polymorphic serializers for flexible response types
  • ✅ Inline serializers for one-off request/response bodies
  • ✅ File upload handling with multipart requests
  • ✅ Custom field schemas with @extend_schema_field
  • ✅ Deprecated fields and endpoints
  • ✅ Error response documentation
  • ✅ Query parameter arrays with different styles
  • ✅ Webhook callbacks
  • ✅ Binary responses for file downloads
  • ✅ Multiple authentication schemes
  • ✅ Response headers
  • ✅ Operation IDs for client generation
  • ✅ Versioning support
This single ViewSet serves as a complete reference for anyone looking to fully utilize drf-spectacular's capabilities. The generated OpenAPI schema from this code will be incredibly detailed and provide excellent documentation for your API consumers.
Remember to regularly validate your schema and test it with tools like Swagger UI and Redoc to ensure everything renders correctly. Happy documenting!