Версионирование Django API

Версионирование позволяет развивать API без нарушения работы существующих клиентов. Это критически важно для production API, которые используют множество клиентов.

Настройка версионирования

Настрой версионирование в settings.py:

1REST_FRAMEWORK = {
2    'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.URLPathVersioning',
3    'DEFAULT_VERSION': 'v1',
4    'ALLOWED_VERSIONS': ['v1', 'v2', 'v3'],
5    'VERSION_PARAM': 'version',
6    'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.URLPathVersioning',
7}

URL версионирование

Самый простой и понятный способ:

 1# project/urls.py
 2from django.urls import path, include
 3
 4urlpatterns = [
 5    path('api/v1/', include('myapp.urls.v1')),
 6    path('api/v2/', include('myapp.urls.v2')),
 7    path('api/v3/', include('myapp.urls.v3')),
 8]
 9
10# myapp/urls/v1.py
11from django.urls import path
12from .views import BookViewSetV1
13
14urlpatterns = [
15    path('books/', BookViewSetV1.as_view({'get': 'list', 'post': 'create'})),
16    path('books/<int:pk>/', BookViewSetV1.as_view({'get': 'retrieve', 'put': 'update', 'delete': 'destroy'})),
17]
18
19# myapp/urls/v2.py
20from django.urls import path
21from .views import BookViewSetV2
22
23urlpatterns = [
24    path('books/', BookViewSetV2.as_view({'get': 'list', 'post': 'create'})),
25    path('books/<int:pk>/', BookViewSetV2.as_view({'get': 'retrieve', 'put': 'update', 'delete': 'destroy'})),
26    path('books/<int:pk>/reviews/', BookViewSetV2.as_view({'get': 'reviews'})),
27]

Header версионирование

Версионирование через HTTP заголовки:

 1REST_FRAMEWORK = {
 2    'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.AcceptHeaderVersioning',
 3    'DEFAULT_VERSION': 'v1',
 4    'ALLOWED_VERSIONS': ['v1', 'v2'],
 5}
 6
 7# Клиент отправляет заголовок:
 8# Accept: application/json; version=v2
 9
10# В view можно получить версию:
11def get(self, request, *args, **kwargs):
12    version = request.version
13    if version == 'v1':
14        return self.v1_response()
15    elif version == 'v2':
16        return self.v2_response()
17    return self.default_response()

Namespace версионирование

Версионирование через пространства имен:

 1REST_FRAMEWORK = {
 2    'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.NamespaceVersioning',
 3    'DEFAULT_VERSION': 'v1',
 4    'ALLOWED_VERSIONS': ['v1', 'v2'],
 5}
 6
 7# urls.py
 8urlpatterns = [
 9    path('api/', include(('myapp.urls.v1', 'v1'), namespace='v1')),
10    path('api/', include(('myapp.urls.v2', 'v2'), namespace='v2')),
11]

Версионирование сериализаторов

Создавай разные сериализаторы для разных версий:

 1# serializers.py
 2from rest_framework import serializers
 3from .models import Book
 4
 5class BookSerializerV1(serializers.ModelSerializer):
 6    class Meta:
 7        model = Book
 8        fields = ['id', 'title', 'author', 'price']
 9
10class BookSerializerV2(serializers.ModelSerializer):
11    author_name = serializers.CharField(source='author.name', read_only=True)
12    category = serializers.CharField()
13    published_date = serializers.DateField()
14
15    class Meta:
16        model = Book
17        fields = ['id', 'title', 'author', 'author_name', 'price', 'category', 'published_date']
18
19class BookSerializerV3(BookSerializerV2):
20    reviews_count = serializers.SerializerMethodField()
21    average_rating = serializers.SerializerMethodField()
22
23    def get_reviews_count(self, obj):
24        return obj.reviews.count()
25
26    def get_average_rating(self, obj):
27        reviews = obj.reviews.all()
28        if reviews:
29            return sum(review.rating for review in reviews) / len(reviews)
30        return 0
31
32    class Meta(BookSerializerV2.Meta):
33        fields = BookSerializerV2.Meta.fields + ['reviews_count', 'average_rating']

Версионирование views

Создавай разные views для разных версий:

 1# views.py
 2from rest_framework import viewsets
 3from rest_framework.decorators import action
 4from rest_framework.response import Response
 5from .models import Book
 6from .serializers import BookSerializerV1, BookSerializerV2, BookSerializerV3
 7
 8class BookViewSetV1(viewsets.ModelViewSet):
 9    queryset = Book.objects.all()
10    serializer_class = BookSerializerV1
11
12    def get_queryset(self):
13        return Book.objects.select_related('author')
14
15class BookViewSetV2(BookViewSetV1):
16    serializer_class = BookSerializerV2
17
18    def get_queryset(self):
19        return Book.objects.select_related('author').prefetch_related('reviews')
20
21    @action(detail=True, methods=['get'])
22    def reviews(self, request, pk=None):
23        book = self.get_object()
24        reviews = book.reviews.all()
25        return Response({'reviews': [{'id': r.id, 'rating': r.rating} for r in reviews]})
26
27class BookViewSetV3(BookViewSetV2):
28    serializer_class = BookSerializerV3
29
30    def get_queryset(self):
31        return Book.objects.select_related('author').prefetch_related('reviews', 'category')
32
33    @action(detail=True, methods=['get'])
34    def statistics(self, request, pk=None):
35        book = self.get_object()
36        return Response({
37            'total_reviews': book.reviews.count(),
38            'average_rating': book.reviews.aggregate(Avg('rating'))['rating__avg'],
39            'last_review': book.reviews.last().created_at if book.reviews.exists() else None
40        })

Условная логика в views

Обрабатывай разные версии в одном view:

 1class BookViewSet(viewsets.ModelViewSet):
 2    queryset = Book.objects.all()
 3
 4    def get_serializer_class(self):
 5        version = self.request.version
 6
 7        if version == 'v1':
 8            return BookSerializerV1
 9        elif version == 'v2':
10            return BookSerializerV2
11        elif version == 'v3':
12            return BookSerializerV3
13        else:
14            return BookSerializerV1  # По умолчанию
15
16    def get_queryset(self):
17        version = self.request.version
18        queryset = Book.objects.all()
19
20        if version in ['v2', 'v3']:
21            queryset = queryset.select_related('author').prefetch_related('reviews')
22
23        if version == 'v3':
24            queryset = queryset.prefetch_related('category')
25
26        return queryset
27
28    def list(self, request, *args, **kwargs):
29        response = super().list(request, *args, **kwargs)
30
31        # Добавляем метаданные в зависимости от версии
32        if request.version == 'v3':
33            response.data['meta'] = {
34                'total_count': self.get_queryset().count(),
35                'version': 'v3',
36                'features': ['reviews', 'statistics', 'category']
37            }
38
39        return response

Версионирование permissions

Разные права доступа для разных версий:

 1from rest_framework import permissions
 2
 3class VersionBasedPermission(permissions.BasePermission):
 4    def has_permission(self, request, view):
 5        version = request.version
 6
 7        # v1 - только чтение
 8        if version == 'v1':
 9            return request.method in permissions.SAFE_METHODS
10
11        # v2 - чтение и создание
12        elif version == 'v2':
13            return request.method in permissions.SAFE_METHODS + ['POST']
14
15        # v3 - полный доступ
16        elif version == 'v3':
17            return True
18
19        return False
20
21class BookViewSet(viewsets.ModelViewSet):
22    permission_classes = [VersionBasedPermission]
23    # ... остальной код

Версионирование фильтрации

Разные фильтры для разных версий:

 1from django_filters import rest_framework as filters
 2
 3class BookFilterV1(filters.FilterSet):
 4    title = filters.CharFilter(lookup_expr='icontains')
 5    author = filters.CharFilter(lookup_expr='icontains')
 6
 7    class Meta:
 8        model = Book
 9        fields = ['title', 'author']
10
11class BookFilterV2(BookFilterV1):
12    price_min = filters.NumberFilter(field_name='price', lookup_expr='gte')
13    price_max = filters.NumberFilter(field_name='price', lookup_expr='lte')
14
15    class Meta(BookFilterV1.Meta):
16        fields = BookFilterV1.Meta.fields + ['price_min', 'price_max']
17
18class BookFilterV3(BookFilterV2):
19    category = filters.CharFilter()
20    published_after = filters.DateFilter(field_name='published_date', lookup_expr='gte')
21
22    class Meta(BookFilterV2.Meta):
23        fields = BookFilterV2.Meta.fields + ['category', 'published_after']
24
25class BookViewSet(viewsets.ModelViewSet):
26    def get_filterset_class(self):
27        version = self.request.version
28
29        if version == 'v1':
30            return BookFilterV1
31        elif version == 'v2':
32            return BookFilterV2
33        elif version == 'v3':
34            return BookFilterV3
35        else:
36            return BookFilterV1

Версионирование пагинации

Разные настройки пагинации:

 1from rest_framework.pagination import PageNumberPagination, LimitOffsetPagination
 2
 3class BookPaginationV1(PageNumberPagination):
 4    page_size = 20
 5    page_size_query_param = 'page_size'
 6    max_page_size = 100
 7
 8class BookPaginationV2(LimitOffsetPagination):
 9    default_limit = 25
10    limit_query_param = 'limit'
11    offset_query_param = 'offset'
12    max_limit = 200
13
14class BookPaginationV3(PageNumberPagination):
15    page_size = 50
16    page_size_query_param = 'page_size'
17    max_page_size = 500
18
19class BookViewSet(viewsets.ModelViewSet):
20    def get_pagination_class(self):
21        version = self.request.version
22
23        if version == 'v1':
24            return BookPaginationV1
25        elif version == 'v2':
26            return BookPaginationV2
27        elif version == 'v3':
28            return BookPaginationV3
29        else:
30            return BookPaginationV1

Обратная совместимость

Обеспечивай совместимость между версиями:

 1class BookSerializerV2(serializers.ModelSerializer):
 2    # Новые поля
 3    category = serializers.CharField()
 4    published_date = serializers.DateField()
 5
 6    # Поля для обратной совместимости
 7    title = serializers.CharField()
 8    author = serializers.CharField(source='author.name')
 9
10    def to_representation(self, instance):
11        data = super().to_representation(instance)
12
13        # Добавляем поля по умолчанию для v1 клиентов
14        if not data.get('category'):
15            data['category'] = 'unknown'
16
17        if not data.get('published_date'):
18            data['published_date'] = instance.created_at.date()
19
20        return data
21
22    class Meta:
23        model = Book
24        fields = ['id', 'title', 'author', 'category', 'published_date']

Тестирование версий

Создавай тесты для разных версий:

 1from django.test import TestCase
 2from django.urls import reverse
 3from rest_framework.test import APIClient
 4from .models import Book, Author
 5
 6class BookAPIVersioningTest(TestCase):
 7    def setUp(self):
 8        self.client = APIClient()
 9        self.author = Author.objects.create(name='Test Author')
10        self.book = Book.objects.create(
11            title='Test Book',
12            author=self.author,
13            price=19.99
14        )
15
16    def test_v1_api(self):
17        url = reverse('v1:book-list')
18        response = self.client.get(url)
19
20        self.assertEqual(response.status_code, 200)
21        self.assertIn('title', response.data[0])
22        self.assertNotIn('category', response.data[0])  # v1 не имеет этого поля
23
24    def test_v2_api(self):
25        url = reverse('v2:book-list')
26        response = self.client.get(url)
27
28        self.assertEqual(response.status_code, 200)
29        self.assertIn('category', response.data[0])  # v2 имеет это поле
30
31    def test_v3_api(self):
32        url = reverse('v3:book-list')
33        response = self.client.get(url)
34
35        self.assertEqual(response.status_code, 200)
36        self.assertIn('reviews_count', response.data[0])  # v3 имеет это поле

Документация API

Используй drf-spectacular для автоматической документации:

 1# settings.py
 2INSTALLED_APPS = [
 3    # ... другие приложения
 4    'drf_spectacular',
 5]
 6
 7SPECTACULAR_SETTINGS = {
 8    'TITLE': 'Book API',
 9    'DESCRIPTION': 'API для управления книгами',
10    'VERSION': '1.0.0',
11    'SERVE_INCLUDE_SCHEMA': False,
12    'COMPONENT_SPLIT_REQUEST': True,
13    'SCHEMA_PATH_PREFIX': '/api/',
14}
15
16# urls.py
17from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
18
19urlpatterns = [
20    path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
21    path('api/docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),
22    # ... версионированные URL
23]

Лучшие практики

  • Всегда поддерживай обратную совместимость
  • Используй семантическое версионирование (v1, v2, v3)
  • Документируй изменения между версиями
  • Тестируй все версии API
  • Используй URL версионирование для простоты
  • Создавай отдельные сериализаторы для каждой версии
  • Планируй deprecation старых версий
  • Мониторь использование разных версий
  • Предоставляй миграционные гайды для клиентов

FAQ

Q: Как обрабатывать разные версии в одном view?
A: Используй request.version для определения версии и условной логики.

Q: Какой способ версионирования лучше?
A: URL версионирование проще для понимания, Header версионирование более гибкое.

Q: Как обеспечить обратную совместимость?
A: Не удаляй поля, только добавляй новые, используй значения по умолчанию.

Q: Когда deprecate старые версии?
A: После того как большинство клиентов перешли на новую версию, обычно через 6-12 месяцев.

Q: Как тестировать разные версии?
A: Создавай отдельные тесты для каждой версии и тестируй совместимость.

Q: Нужно ли версионировать все endpoints?
A: Нет, только те, которые изменяются. Стабильные endpoints могут оставаться без версий.