Просмотр исходного кода

Closes #21356: Implement ETag support for REST API

Jeremy Stretch 5 дней назад
Родитель
Сommit
856701d8aa
2 измененных файлов с 91 добавлено и 3 удалено
  1. 45 2
      netbox/netbox/api/viewsets/__init__.py
  2. 46 1
      netbox/utilities/testing/api.py

+ 45 - 2
netbox/netbox/api/viewsets/__init__.py

@@ -34,6 +34,28 @@ HTTP_ACTIONS = {
 }
 
 
+class ETagMixin:
+    """
+    Adds ETag header support to ViewSets. Generates ETags from `last_updated`
+    (or `created` if unavailable).
+    """
+
+    @staticmethod
+    def _get_etag(obj):
+        """Return a quoted ETag string for the given object, or None."""
+        if ts := getattr(obj, 'last_updated', None) or getattr(obj, 'created', None):
+            return f'"{ts.isoformat()}"'
+        return None
+
+    def retrieve(self, request, *args, **kwargs):
+        instance = self.get_object()
+        serializer = self.get_serializer(instance)
+        response = Response(serializer.data)
+        if etag := self._get_etag(instance):
+            response['ETag'] = etag
+        return response
+
+
 class BaseViewSet(GenericViewSet):
     """
     Base class for all API ViewSets. This is responsible for the enforcement of object-based permissions.
@@ -95,6 +117,7 @@ class BaseViewSet(GenericViewSet):
 
 
 class NetBoxReadOnlyModelViewSet(
+    ETagMixin,
     mixins.CustomFieldsMixin,
     mixins.ExportTemplatesMixin,
     drf_mixins.RetrieveModelMixin,
@@ -105,6 +128,7 @@ class NetBoxReadOnlyModelViewSet(
 
 
 class NetBoxModelViewSet(
+    ETagMixin,
     mixins.BulkUpdateModelMixin,
     mixins.BulkDestroyModelMixin,
     mixins.ObjectValidationMixin,
@@ -191,7 +215,14 @@ class NetBoxModelViewSet(
         serializer = self.get_serializer(qs, many=bulk_create)
 
         headers = self.get_success_headers(serializer.data)
-        return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
+        response = Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
+
+        # Add ETag for single-object creation only (bulk returns a list, no single ETag)
+        if not bulk_create:
+            if etag := self._get_etag(qs):
+                response['ETag'] = etag
+
+        return response
 
     def perform_create(self, serializer):
         model = self.queryset.model
@@ -211,6 +242,14 @@ class NetBoxModelViewSet(
     def update(self, request, *args, **kwargs):
         partial = kwargs.pop('partial', False)
         instance = self.get_object_with_snapshot()
+
+        # Enforce If-Match precondition (RFC 9110 §13.1.1)
+        if (if_match := request.META.get('HTTP_IF_MATCH')) and if_match != '*':
+            current_etag = self._get_etag(instance)
+            provided = [e.strip() for e in if_match.split(',')]
+            if current_etag and current_etag not in provided:
+                return Response(status=status.HTTP_412_PRECONDITION_FAILED)
+
         serializer = self.get_serializer(instance, data=request.data, partial=partial)
         serializer.is_valid(raise_exception=True)
         self.perform_update(serializer)
@@ -221,8 +260,12 @@ class NetBoxModelViewSet(
 
         # Re-serialize the instance(s) with prefetched data
         serializer = self.get_serializer(qs)
+        response = Response(serializer.data)
+
+        if etag := self._get_etag(qs):
+            response['ETag'] = etag
 
-        return Response(serializer.data)
+        return response
 
     def perform_update(self, serializer):
         model = self.queryset.model

+ 46 - 1
netbox/utilities/testing/api.py

@@ -114,7 +114,12 @@ class APIViewTestCases:
 
             # Try GET to permitted object
             url = self._get_detail_url(instance1)
-            self.assertHttpStatus(self.client.get(url, **self.header), status.HTTP_200_OK)
+            response = self.client.get(url, **self.header)
+            self.assertHttpStatus(response, status.HTTP_200_OK)
+
+            # Verify ETag header is present for objects with timestamps
+            if issubclass(self.model, ChangeLoggingMixin):
+                self.assertIn('ETag', response, "ETag header missing from detail response")
 
             # Try GET to non-permitted object
             url = self._get_detail_url(instance2)
@@ -367,6 +372,46 @@ class APIViewTestCases:
                 self.assertEqual(objectchange.action, ObjectChangeActionChoices.ACTION_UPDATE)
                 self.assertEqual(objectchange.message, data['changelog_message'])
 
+        def test_update_object_with_etag(self):
+            """
+            PATCH an object using a valid If-Match ETag → expect 200.
+            PATCH again with the now-stale ETag → expect 412.
+            """
+            if not issubclass(self.model, ChangeLoggingMixin):
+                self.skipTest("Model does not support ETags")
+
+            self.add_permissions(
+                f'{self.model._meta.app_label}.view_{self.model._meta.model_name}',
+                f'{self.model._meta.app_label}.change_{self.model._meta.model_name}',
+            )
+            instance = self._get_queryset().first()
+            url = self._get_detail_url(instance)
+            update_data = self.update_data or getattr(self, 'create_data')[0]
+
+            # Fetch current ETag
+            get_response = self.client.get(url, **self.header)
+            self.assertHttpStatus(get_response, status.HTTP_200_OK)
+            etag = get_response.get('ETag')
+            self.assertIsNotNone(etag, "No ETag returned by GET")
+
+            # PATCH with correct ETag → 200
+            response = self.client.patch(
+                url, update_data, format='json',
+                **{**self.header, 'HTTP_IF_MATCH': etag}
+            )
+            self.assertHttpStatus(response, status.HTTP_200_OK)
+            new_etag = response.get('ETag')
+            self.assertIsNotNone(new_etag)
+            self.assertNotEqual(etag, new_etag)  # ETag must change after update
+
+            # PATCH with the old (stale) ETag → 412
+            with disable_warnings('django.request'):
+                response = self.client.patch(
+                    url, update_data, format='json',
+                    **{**self.header, 'HTTP_IF_MATCH': etag}
+                )
+            self.assertHttpStatus(response, status.HTTP_412_PRECONDITION_FAILED)
+
         def test_bulk_update_objects(self):
             """
             PATCH a set of objects in a single request.