mirror of
https://github.com/netbox-community/netbox.git
synced 2026-02-27 02:57:40 +01:00
Compare commits
1 Commits
feature
...
21356-etag
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
856701d8aa |
@@ -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)
|
||||
|
||||
return Response(serializer.data)
|
||||
if etag := self._get_etag(qs):
|
||||
response['ETag'] = etag
|
||||
|
||||
return response
|
||||
|
||||
def perform_update(self, serializer):
|
||||
model = self.queryset.model
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user