Selaa lähdekoodia

Closes #23290: Improve REST API performance by reusing nested serializers (#23309)

Martin Hauser 3 päivää sitten
vanhempi
sitoutus
564c50cc91

+ 12 - 3
netbox/core/api/serializers_/jobs.py

@@ -1,3 +1,5 @@
+from functools import cached_property
+
 from drf_spectacular.utils import extend_schema_field
 from drf_spectacular.utils import extend_schema_field
 from rest_framework import serializers
 from rest_framework import serializers
 
 
@@ -37,6 +39,10 @@ class JobSerializer(BaseModelSerializer):
         ]
         ]
         brief_fields = ('url', 'created', 'completed', 'user', 'status')
         brief_fields = ('url', 'created', 'completed', 'user', 'status')
 
 
+    @cached_property
+    def _object_serializers(self):
+        return {}
+
     @extend_schema_field(serializers.JSONField(allow_null=True))
     @extend_schema_field(serializers.JSONField(allow_null=True))
     def get_object(self, obj):
     def get_object(self, obj):
         """
         """
@@ -45,8 +51,11 @@ class JobSerializer(BaseModelSerializer):
         if obj.object is None:
         if obj.object is None:
             return None
             return None
         try:
         try:
-            serializer = get_serializer_for_model(obj.object)
+            serializer_class = get_serializer_for_model(obj.object)
         except SerializerNotFound:
         except SerializerNotFound:
             return obj.object_repr
             return obj.object_repr
-        context = {'request': self.context['request']}
-        return serializer(obj.object, nested=True, context=context).data
+        if serializer_class not in self._object_serializers:
+            self._object_serializers[serializer_class] = serializer_class(
+                nested=True, context={'request': self.context['request']}
+            )
+        return self._object_serializers[serializer_class].to_representation(obj.object)

+ 37 - 0
netbox/core/tests/test_api.py

@@ -12,6 +12,8 @@ from rq.job import Job as RQ_Job
 from rq.job import JobStatus
 from rq.job import JobStatus
 from rq.registry import FailedJobRegistry, StartedJobRegistry
 from rq.registry import FailedJobRegistry, StartedJobRegistry
 
 
+from core.api.serializers import DataSourceSerializer
+from dcim.models import Site
 from users.constants import TOKEN_PREFIX
 from users.constants import TOKEN_PREFIX
 from users.models import ObjectPermission, Token
 from users.models import ObjectPermission, Token
 from utilities.testing import APITestCase, APIViewTestCases, GraphQLQueryTest, TestCase, create_tags
 from utilities.testing import APITestCase, APIViewTestCases, GraphQLQueryTest, TestCase, create_tags
@@ -339,6 +341,41 @@ class JobTestCase(
         self.assertHttpStatus(response, status.HTTP_200_OK)
         self.assertHttpStatus(response, status.HTTP_200_OK)
         self.assertEqual(response.data['count'], 3)
         self.assertEqual(response.data['count'], 3)
 
 
+    def test_list_objects_reuse_object_serializer(self):
+        """Jobs share one nested serializer per object type per request."""
+        self.add_permissions('core.view_job')
+        datasource = DataSource.objects.create(
+            name='Data Source 2', type='local', source_url='file:///var/tmp/source2/'
+        )
+        Job.objects.filter(name='Job 3').update(object_id=datasource.pk)
+        site = Site.objects.create(name='Site 1', slug='site-1')
+        Job.objects.create(
+            name='Job 4', object_type=ContentType.objects.get_for_model(Site), object_id=site.pk, status='completed',
+            queue_name='default', job_id=uuid.uuid4(),
+        )
+        url = reverse('core-api:job-list')
+
+        def get(params, host):
+            with patch.object(
+                DataSourceSerializer, '__init__', autospec=True, side_effect=DataSourceSerializer.__init__
+            ) as init:
+                response = self.client.get(url, {'fields': 'name,object', **params}, HTTP_HOST=host, **self.header)
+            self.assertHttpStatus(response, status.HTTP_200_OK)
+            return init.call_count, {job['name']: job['object'] for job in response.data['results']}
+
+        one, _ = get({'id': Job.objects.get(name='Job 1').pk}, 'a.example.com')
+        four, objects = get({}, 'b.example.com')
+        self.assertEqual(one, four)
+        self.assertEqual(
+            {name: obj['name'] for name, obj in objects.items()},
+            {'Job 1': 'Data Source 1', 'Job 2': 'Data Source 1', 'Job 3': 'Data Source 2', 'Job 4': 'Site 1'},
+        )
+        self.assertTrue(objects['Job 3']['url'].startswith('http://b.example.com/'))
+        # Only the site serializer's brief fields carry a slug
+        self.assertEqual({name for name, obj in objects.items() if 'slug' in obj}, {'Job 4'})
+        for obj in objects.values():
+            self.assertNotIn('custom_fields', obj)
+
 
 
 class BackgroundTaskTestCase(RQQueueTestMixin, TestCase):
 class BackgroundTaskTestCase(RQQueueTestMixin, TestCase):
     user_permissions = ()
     user_permissions = ()

+ 12 - 3
netbox/dcim/api/serializers_/base.py

@@ -1,3 +1,5 @@
+from functools import cached_property
+
 from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.utils import extend_schema_field
 from drf_spectacular.utils import extend_schema_field
 from rest_framework import serializers
 from rest_framework import serializers
@@ -26,15 +28,22 @@ class ConnectedEndpointsSerializer(serializers.ModelSerializer):
             return f'{endpoints[0]._meta.app_label}.{endpoints[0]._meta.model_name}'
             return f'{endpoints[0]._meta.app_label}.{endpoints[0]._meta.model_name}'
         return None
         return None
 
 
+    @cached_property
+    def _connected_endpoint_serializers(self):
+        return {}
+
     @extend_schema_field(serializers.ListField(allow_null=True))
     @extend_schema_field(serializers.ListField(allow_null=True))
     def get_connected_endpoints(self, obj):
     def get_connected_endpoints(self, obj):
         """
         """
         Return the appropriate serializer for the type of connected object.
         Return the appropriate serializer for the type of connected object.
         """
         """
         if endpoints := obj.connected_endpoints:
         if endpoints := obj.connected_endpoints:
-            serializer = get_serializer_for_model(endpoints[0])
-            context = {'request': self.context['request']}
-            return serializer(endpoints, nested=True, many=True, context=context).data
+            serializer_class = get_serializer_for_model(endpoints[0])
+            if serializer_class not in self._connected_endpoint_serializers:
+                self._connected_endpoint_serializers[serializer_class] = serializer_class(
+                    nested=True, many=True, context={'request': self.context['request']}
+                )
+            return self._connected_endpoint_serializers[serializer_class].to_representation(endpoints)
         return None
         return None
 
 
     @extend_schema_field(serializers.BooleanField)
     @extend_schema_field(serializers.BooleanField)

+ 22 - 6
netbox/dcim/api/serializers_/cables.py

@@ -1,3 +1,5 @@
+from functools import cached_property
+
 from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.utils import extend_schema_field
 from drf_spectacular.utils import extend_schema_field
 from rest_framework import serializers
 from rest_framework import serializers
@@ -94,6 +96,10 @@ class CablePathSerializer(serializers.ModelSerializer):
         model = CablePath
         model = CablePath
         fields = ['id', 'path', 'is_active', 'is_complete', 'is_split']
         fields = ['id', 'path', 'is_active', 'is_complete', 'is_split']
 
 
+    @cached_property
+    def _node_serializers(self):
+        return {}
+
     @extend_schema_field(serializers.ListField)
     @extend_schema_field(serializers.ListField)
     def get_path(self, obj):
     def get_path(self, obj):
         ret = []
         ret = []
@@ -101,9 +107,12 @@ class CablePathSerializer(serializers.ModelSerializer):
             if not nodes:
             if not nodes:
                 # The path contains an invalid object
                 # The path contains an invalid object
                 return []
                 return []
-            serializer = get_serializer_for_model(nodes[0])
-            context = {'request': self.context['request']}
-            ret.append(serializer(nodes, nested=True, many=True, context=context).data)
+            serializer_class = get_serializer_for_model(nodes[0])
+            if serializer_class not in self._node_serializers:
+                self._node_serializers[serializer_class] = serializer_class(
+                    nested=True, many=True, context={'request': self.context['request']}
+                )
+            ret.append(self._node_serializers[serializer_class].to_representation(nodes))
         return ret
         return ret
 
 
 
 
@@ -128,6 +137,10 @@ class CabledObjectSerializer(serializers.ModelSerializer):
 
 
         return None
         return None
 
 
+    @cached_property
+    def _link_peer_serializers(self):
+        return {}
+
     @extend_schema_field(serializers.ListField)
     @extend_schema_field(serializers.ListField)
     def get_link_peers(self, obj):
     def get_link_peers(self, obj):
         """
         """
@@ -137,9 +150,12 @@ class CabledObjectSerializer(serializers.ModelSerializer):
             return []
             return []
 
 
         # Return serialized peer termination objects
         # Return serialized peer termination objects
-        serializer = get_serializer_for_model(obj.link_peers[0])
-        context = {'request': self.context['request']}
-        return serializer(obj.link_peers, nested=True, many=True, context=context).data
+        serializer_class = get_serializer_for_model(obj.link_peers[0])
+        if serializer_class not in self._link_peer_serializers:
+            self._link_peer_serializers[serializer_class] = serializer_class(
+                nested=True, many=True, context={'request': self.context['request']}
+            )
+        return self._link_peer_serializers[serializer_class].to_representation(obj.link_peers)
 
 
     @extend_schema_field(serializers.BooleanField)
     @extend_schema_field(serializers.BooleanField)
     def get__occupied(self, obj):
     def get__occupied(self, obj):

+ 11 - 3
netbox/dcim/api/serializers_/devices.py

@@ -1,4 +1,5 @@
 import decimal
 import decimal
+from functools import cached_property
 
 
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
@@ -116,15 +117,22 @@ class DeviceSerializer(PrimaryModelSerializer):
         ]
         ]
         brief_fields = ('id', 'url', 'display', 'name', 'description')
         brief_fields = ('id', 'url', 'display', 'name', 'description')
 
 
+    @cached_property
+    def _parent_device_serializer(self):
+        return NestedDeviceSerializer(context={'request': self.context['request']})
+
+    @cached_property
+    def _parent_bay_serializer(self):
+        return NestedDeviceBaySerializer(context={'request': self.context['request']})
+
     @extend_schema_field(NestedDeviceSerializer(allow_null=True))
     @extend_schema_field(NestedDeviceSerializer(allow_null=True))
     def get_parent_device(self, obj):
     def get_parent_device(self, obj):
         try:
         try:
             device_bay = obj.parent_bay
             device_bay = obj.parent_bay
         except DeviceBay.DoesNotExist:
         except DeviceBay.DoesNotExist:
             return None
             return None
-        context = {'request': self.context['request']}
-        data = NestedDeviceSerializer(instance=device_bay.device, context=context).data
-        data['device_bay'] = NestedDeviceBaySerializer(instance=device_bay, context=context).data
+        data = self._parent_device_serializer.to_representation(device_bay.device)
+        data['device_bay'] = self._parent_bay_serializer.to_representation(device_bay)
         return data
         return data
 
 
     @extend_schema_field(serializers.JSONField(allow_null=True))
     @extend_schema_field(serializers.JSONField(allow_null=True))

+ 125 - 0
netbox/dcim/tests/test_api.py

@@ -1,4 +1,5 @@
 import json
 import json
+from unittest.mock import patch
 
 
 from django.conf import settings
 from django.conf import settings
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
@@ -11,6 +12,8 @@ from rest_framework import status
 
 
 from core.choices import ObjectChangeActionChoices
 from core.choices import ObjectChangeActionChoices
 from core.models import ObjectChange, ObjectType
 from core.models import ObjectChange, ObjectType
+from dcim.api.serializers import InterfaceSerializer
+from dcim.api.serializers_.nested import NestedDeviceBaySerializer, NestedDeviceSerializer
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
 from dcim.graphql.types import _CABLE_TERMINATION_MODELS
 from dcim.graphql.types import _CABLE_TERMINATION_MODELS
@@ -2658,6 +2661,54 @@ class DeviceTestCase(APIViewTestCases.APIViewTestCase):
 
 
         self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
         self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
 
 
+    def test_list_objects_reuse_parent_device_serializers(self):
+        """Installed child devices share one nested parent serializer pair per request."""
+        self.add_permissions('dcim.view_device')
+        device = Device.objects.get(name='Device 1')
+        manufacturer = device.device_type.manufacturer
+        parent_type = DeviceType.objects.create(
+            manufacturer=manufacturer, model='Parent Type', slug='parent-type',
+            subdevice_role=SubdeviceRoleChoices.ROLE_PARENT,
+        )
+        child_type = DeviceType.objects.create(
+            manufacturer=manufacturer, model='Child Type', slug='child-type',
+            subdevice_role=SubdeviceRoleChoices.ROLE_CHILD, u_height=0,
+        )
+        parent = Device.objects.create(device_type=parent_type, role=device.role, site=device.site, name='Parent')
+        children = []
+        for i in (1, 2):
+            child = Device.objects.create(
+                device_type=child_type, role=device.role, site=device.site, name=f'Child {i}'
+            )
+            DeviceBay.objects.create(device=parent, name=f'Bay {i}', installed_device=child)
+            children.append(child.pk)
+        url = reverse('dcim-api:device-list')
+
+        def get(ids, host):
+            with (
+                patch.object(
+                    NestedDeviceSerializer, '__init__', autospec=True, side_effect=NestedDeviceSerializer.__init__
+                ) as device_init,
+                patch.object(
+                    NestedDeviceBaySerializer, '__init__', autospec=True, side_effect=NestedDeviceBaySerializer.__init__
+                ) as bay_init,
+            ):
+                response = self.client.get(
+                    url, {'id': ids, 'fields': 'name,parent_device'}, HTTP_HOST=host, **self.header
+                )
+            self.assertHttpStatus(response, status.HTTP_200_OK)
+            constructions = (device_init.call_count, bay_init.call_count)
+            return constructions, {row['name']: row['parent_device'] for row in response.data['results']}
+
+        one, _ = get(children[:1], 'a.example.com')
+        three, parents = get([*children, device.pk], 'b.example.com')
+        self.assertEqual(one, three)
+        self.assertIsNone(parents['Device 1'])
+        for i in (1, 2):
+            self.assertEqual(parents[f'Child {i}']['id'], parent.pk)
+            self.assertEqual(parents[f'Child {i}']['device_bay']['name'], f'Bay {i}')
+            self.assertTrue(parents[f'Child {i}']['url'].startswith('http://b.example.com/'))
+
     def test_render_config(self):
     def test_render_config(self):
         configtemplate = ConfigTemplate.objects.create(
         configtemplate = ConfigTemplate.objects.create(
             name='Config Template 1',
             name='Config Template 1',
@@ -3888,6 +3939,40 @@ class InterfaceTestCase(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTest
         # Tagged-all mode, qinq service vlan
         # Tagged-all mode, qinq service vlan
         self._perform_interface_test_with_invalid_data(InterfaceModeChoices.MODE_TAGGED_ALL, invalid_data)
         self._perform_interface_test_with_invalid_data(InterfaceModeChoices.MODE_TAGGED_ALL, invalid_data)
 
 
+    def test_list_objects_reuse_peer_serializers(self):
+        """Link peers and connected endpoints share one nested serializer each per request."""
+        self.add_permissions('dcim.view_interface')
+        device = Device.objects.get(name='Device 1')
+        peer_device = Device.objects.create(
+            device_type=device.device_type, role=device.role, site=device.site, name='Peer Device'
+        )
+        peers = {}
+        for name in ('Interface 1', 'Interface 2'):
+            peer = Interface.objects.create(device=peer_device, name=f'Peer {name}', type='1000base-t')
+            Cable(a_terminations=[Interface.objects.get(device=device, name=name)], b_terminations=[peer]).save()
+            peers[name] = peer.pk
+        url = reverse('dcim-api:interface-list')
+
+        def get(params, host):
+            with patch.object(
+                InterfaceSerializer, '__init__', autospec=True, side_effect=InterfaceSerializer.__init__
+            ) as init:
+                response = self.client.get(url, {
+                    'device_id': device.pk, 'fields': 'name,link_peers,connected_endpoints', **params,
+                }, HTTP_HOST=host, **self.header)
+            self.assertHttpStatus(response, status.HTTP_200_OK)
+            return init.call_count, {interface['name']: interface for interface in response.data['results']}
+
+        one, _ = get({'name': 'Interface 1'}, 'a.example.com')
+        three, interfaces = get({}, 'b.example.com')
+        self.assertEqual(one, three)
+        for name, peer_id in peers.items():
+            self.assertEqual([peer['id'] for peer in interfaces[name]['link_peers']], [peer_id])
+            self.assertEqual([peer['id'] for peer in interfaces[name]['connected_endpoints']], [peer_id])
+            self.assertTrue(interfaces[name]['link_peers'][0]['url'].startswith('http://b.example.com/'))
+        self.assertEqual(interfaces['Interface 3']['link_peers'], [])
+        self.assertIsNone(interfaces['Interface 3']['connected_endpoints'])
+
     def test_mac_address_create(self):
     def test_mac_address_create(self):
         """
         """
         Creating an interface with mac_address creates the primary MACAddress in one request.
         Creating an interface with mac_address creates the primary MACAddress in one request.
@@ -4460,6 +4545,46 @@ class RearPortTestCase(APIViewTestCases.APIViewTestCase):
 
 
         self.assertHttpStatus(response, status.HTTP_200_OK)
         self.assertHttpStatus(response, status.HTTP_200_OK)
 
 
+    def test_paths_reuse_node_serializers(self):
+        """The paths through a rear port share one nested serializer per node type."""
+        device = Device.objects.first()
+        rear_ports = {}
+        for positions in (1, 2):
+            rear_port = RearPort.objects.create(
+                device=device, name=f'Path Rear Port {positions}', type=PortTypeChoices.TYPE_8P8C, positions=positions
+            )
+            for position in range(1, positions + 1):
+                front_port = FrontPort.objects.create(
+                    device=device, name=f'Path Front Port {positions}-{position}', type=PortTypeChoices.TYPE_8P8C
+                )
+                PortMapping.objects.create(
+                    device=device, front_port=front_port, rear_port=rear_port, rear_port_position=position
+                )
+                interface = Interface.objects.create(device=device, name=f'Path Interface {positions}-{position}')
+                Cable.objects.create(a_terminations=[interface], b_terminations=[front_port])
+            rear_ports[positions] = rear_port.pk
+        self.add_permissions('dcim.view_rearport')
+
+        def get(positions, host):
+            url = reverse('dcim-api:rearport-paths', kwargs={'pk': rear_ports[positions]})
+            with patch.object(
+                InterfaceSerializer, '__init__', autospec=True, side_effect=InterfaceSerializer.__init__
+            ) as init:
+                response = self.client.get(url, HTTP_HOST=host, **self.header)
+            self.assertHttpStatus(response, status.HTTP_200_OK)
+            return init.call_count, response.data
+
+        one, _ = get(1, 'a.example.com')
+        two, paths = get(2, 'b.example.com')
+        self.assertEqual(one, two)
+        self.assertEqual(
+            sorted(path['path'][0][0]['name'] for path in paths),
+            ['Path Interface 2-1', 'Path Interface 2-2'],
+        )
+        for path in paths:
+            self.assertEqual(path['path'][-1][0]['id'], rear_ports[2])
+            self.assertTrue(path['path'][0][0]['url'].startswith('http://b.example.com/'))
+
 
 
 class ModuleBayTestCase(APIViewTestCases.APIViewTestCase):
 class ModuleBayTestCase(APIViewTestCases.APIViewTestCase):
     model = ModuleBay
     model = ModuleBay

+ 19 - 6
netbox/extras/api/customfields.py

@@ -1,3 +1,5 @@
+from functools import cached_property
+
 from django.core.exceptions import ValidationError as DjangoValidationError
 from django.core.exceptions import ValidationError as DjangoValidationError
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.types import OpenApiTypes
@@ -47,9 +49,22 @@ class CustomFieldsDataField(Field):
             self._custom_fields = CustomField.objects.get_for_model(self.parent.Meta.model)
             self._custom_fields = CustomField.objects.get_for_model(self.parent.Meta.model)
         return self._custom_fields
         return self._custom_fields
 
 
+    @cached_property
+    def _nested_serializers(self):
+        return {}
+
+    def _get_nested_serializer(self, cf, many):
+        """
+        Return the nested serializer for the objects a custom field references, built once per field instance.
+        """
+        serializer_class = get_serializer_for_model(cf.related_object_type.model_class())
+        if (serializer_class, many) not in self._nested_serializers:
+            self._nested_serializers[(serializer_class, many)] = serializer_class(
+                nested=True, many=many, context=self.parent.context
+            )
+        return self._nested_serializers[(serializer_class, many)]
+
     def to_representation(self, obj):
     def to_representation(self, obj):
-        # TODO: Fix circular import
-        from utilities.api import get_serializer_for_model
         data = {}
         data = {}
         cache = self.parent.context.get('cf_object_cache')
         cache = self.parent.context.get('cf_object_cache')
 
 
@@ -71,11 +86,9 @@ class CustomFieldsDataField(Field):
                 value = cf.deserialize(obj.get(cf.name))
                 value = cf.deserialize(obj.get(cf.name))
 
 
             if value is not None and cf.type == CustomFieldTypeChoices.TYPE_OBJECT:
             if value is not None and cf.type == CustomFieldTypeChoices.TYPE_OBJECT:
-                serializer = get_serializer_for_model(cf.related_object_type.model_class())
-                value = serializer(value, nested=True, context=self.parent.context).data
+                value = self._get_nested_serializer(cf, many=False).to_representation(value)
             elif value is not None and cf.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
             elif value is not None and cf.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
-                serializer = get_serializer_for_model(cf.related_object_type.model_class())
-                value = serializer(value, nested=True, many=True, context=self.parent.context).data
+                value = self._get_nested_serializer(cf, many=True).to_representation(value)
             elif cf.type in (CustomFieldTypeChoices.TYPE_SELECT, CustomFieldTypeChoices.TYPE_MULTISELECT):
             elif cf.type in (CustomFieldTypeChoices.TYPE_SELECT, CustomFieldTypeChoices.TYPE_MULTISELECT):
                 value = cf.resolve_selection_value(value)
                 value = cf.resolve_selection_value(value)
             data[cf.name] = value
             data[cf.name] = value

+ 12 - 4
netbox/extras/api/serializers_/tags.py

@@ -1,3 +1,5 @@
+from functools import cached_property
+
 from drf_spectacular.utils import extend_schema_field
 from drf_spectacular.utils import extend_schema_field
 from rest_framework import serializers
 from rest_framework import serializers
 
 
@@ -54,15 +56,21 @@ class TaggedItemSerializer(BaseModelSerializer):
         ]
         ]
         brief_fields = ('id', 'url', 'display', 'object_type', 'object_id', 'object', 'tag')
         brief_fields = ('id', 'url', 'display', 'object_type', 'object_id', 'object', 'tag')
 
 
+    @cached_property
+    def _object_serializers(self):
+        return {}
+
     @extend_schema_field(serializers.JSONField())
     @extend_schema_field(serializers.JSONField())
     def get_object(self, obj):
     def get_object(self, obj):
         """
         """
         Serialize a nested representation of the tagged object.
         Serialize a nested representation of the tagged object.
         """
         """
         try:
         try:
-            serializer = get_serializer_for_model(obj.content_object)
+            serializer_class = get_serializer_for_model(obj.content_object)
         except SerializerNotFound:
         except SerializerNotFound:
             return obj.object_repr
             return obj.object_repr
-        data = serializer(obj.content_object, nested=True, context={'request': self.context['request']}).data
-
-        return data
+        if serializer_class not in self._object_serializers:
+            self._object_serializers[serializer_class] = serializer_class(
+                nested=True, context={'request': self.context['request']}
+            )
+        return self._object_serializers[serializer_class].to_representation(obj.content_object)

+ 27 - 0
netbox/extras/tests/test_api.py

@@ -16,6 +16,7 @@ from rest_framework import status
 from core.choices import JobNotificationChoices, ManagedFileRootPathChoices
 from core.choices import JobNotificationChoices, ManagedFileRootPathChoices
 from core.events import *
 from core.events import *
 from core.models import AutoSyncRecord, DataFile, DataSource, Job, ObjectType
 from core.models import AutoSyncRecord, DataFile, DataSource, Job, ObjectType
+from dcim.api.serializers import SiteSerializer
 from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Rack, RackRole, Site
 from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Rack, RackRole, Site
 from extras.api.serializers import EventRuleSerializer
 from extras.api.serializers import EventRuleSerializer
 from extras.choices import *
 from extras.choices import *
@@ -1321,6 +1322,32 @@ class TaggedItemTestCase(
         sites[1].tags.set([tags[1], tags[2]])
         sites[1].tags.set([tags[1], tags[2]])
         sites[2].tags.set([tags[2], tags[0]])
         sites[2].tags.set([tags[2], tags[0]])
 
 
+    def test_list_objects_reuse_object_serializer(self):
+        """Tagged items share one nested serializer per object type per request."""
+        self.add_permissions('extras.view_taggeditem')
+        rack = Rack.objects.create(name='Rack 1', site=Site.objects.first())
+        rack.tags.set([Tag.objects.get(slug='tag-1')])
+        url = reverse('extras-api:taggeditem-list')
+
+        def get(params, host):
+            with patch.object(SiteSerializer, '__init__', autospec=True, side_effect=SiteSerializer.__init__) as init:
+                response = self.client.get(url, params, HTTP_HOST=host, **self.header)
+            self.assertHttpStatus(response, status.HTTP_200_OK)
+            return init.call_count, response.data['results']
+
+        rack_item = TaggedItem.objects.get(content_type=ContentType.objects.get_for_model(Rack), object_id=rack.pk)
+        # Both sizes hold the rack row, whose serializer copies a declared SiteSerializer field
+        one, _ = get({'id': [TaggedItem.objects.first().pk, rack_item.pk]}, 'a.example.com')
+        seven, tagged_items = get({}, 'b.example.com')
+        self.assertEqual(one, seven)
+        self.assertEqual(len(tagged_items), 7)
+        for tagged_item in tagged_items:
+            self.assertEqual(tagged_item['object']['id'], tagged_item['object_id'])
+            self.assertTrue(tagged_item['object']['url'].startswith('http://b.example.com/'))
+            # Only the site serializer's brief fields carry a slug
+            self.assertEqual('slug' in tagged_item['object'], tagged_item['object_type'] == 'dcim.site')
+            self.assertNotIn('custom_fields', tagged_item['object'])
+
 
 
 # TODO: Standardize to APIViewTestCase (needs create & update tests)
 # TODO: Standardize to APIViewTestCase (needs create & update tests)
 class ImageAttachmentTestCase(
 class ImageAttachmentTestCase(

+ 63 - 0
netbox/extras/tests/test_customfields.py

@@ -22,6 +22,7 @@ from dcim.filtersets import SiteFilterSet
 from dcim.forms import SiteImportForm
 from dcim.forms import SiteImportForm
 from dcim.models import Manufacturer, Rack, Site
 from dcim.models import Manufacturer, Rack, Site
 from dcim.tables import SiteTable
 from dcim.tables import SiteTable
+from extras.api.customfields import CustomFieldListSerializer
 from extras.choices import *
 from extras.choices import *
 from extras.constants import CUSTOMFIELD_JOB_TIMEOUT
 from extras.constants import CUSTOMFIELD_JOB_TIMEOUT
 from extras.filters import MissingKeyAwareFilterMixin, missing_key_aware_filter_factory
 from extras.filters import MissingKeyAwareFilterMixin, missing_key_aware_filter_factory
@@ -32,6 +33,7 @@ from extras.jobs import (
     purge_custom_field,
     purge_custom_field,
 )
 )
 from extras.models import CustomField, CustomFieldChoiceSet
 from extras.models import CustomField, CustomFieldChoiceSet
+from ipam.api.serializers import VLANSerializer
 from ipam.models import VLAN
 from ipam.models import VLAN
 from netbox.choices import CSVDelimiterChoices, ImportFormatChoices
 from netbox.choices import CSVDelimiterChoices, ImportFormatChoices
 from netbox.context import query_cache
 from netbox.context import query_cache
@@ -1488,6 +1490,67 @@ class CustomFieldAPITestCase(APITestCase):
             [obj.pk for obj in site2_cfvs['multiobject_field']]
             [obj.pk for obj in site2_cfvs['multiobject_field']]
         )
         )
 
 
+    def test_list_objects_reuse_object_field_serializers(self):
+        """Object and multi-object values share one nested serializer each per request."""
+        vlans = {vlan.name: vlan for vlan in VLAN.objects.all()}
+        site2 = Site.objects.get(name='Site 2')
+        site3 = Site.objects.create(name='Site 3', slug='site-3', custom_field_data={
+            'object_field': vlans['VLAN 1'].pk,
+            'multiobject_field': [vlans['VLAN 4'].pk, vlans['VLAN 5'].pk],
+        })
+        url = reverse('dcim-api:site-list')
+        self.add_permissions('dcim.view_site')
+
+        def get(ids, host):
+            with patch.object(VLANSerializer, '__init__', autospec=True, side_effect=VLANSerializer.__init__) as init:
+                response = self.client.get(
+                    url, {'id': ids, 'fields': 'name,custom_fields'}, HTTP_HOST=host, **self.header
+                )
+            self.assertHttpStatus(response, status.HTTP_200_OK)
+            return init.call_count, {site['name']: site['custom_fields'] for site in response.data['results']}
+
+        one, _ = get([site2.pk], 'a.example.com')
+        two, custom_fields = get([site2.pk, site3.pk], 'b.example.com')
+        self.assertEqual(one, two)
+        expected = {
+            'Site 2': ('VLAN 2', ['VLAN 3', 'VLAN 4']),
+            'Site 3': ('VLAN 1', ['VLAN 4', 'VLAN 5']),
+        }
+        for name, (object_value, multiobject_values) in expected.items():
+            self.assertEqual(custom_fields[name]['object_field']['id'], vlans[object_value].pk)
+            self.assertEqual(
+                [vlan['id'] for vlan in custom_fields[name]['multiobject_field']],
+                [vlans[value].pk for value in multiobject_values],
+            )
+        self.assertTrue(custom_fields['Site 3']['object_field']['url'].startswith('http://b.example.com/'))
+        self.assertNotIn('custom_fields', custom_fields['Site 3']['object_field'])
+
+    def test_list_objects_use_list_serializer_for_multiobject_values(self):
+        """Each multi-object value goes through the list serializer of its target."""
+        vlans = {vlan.name: vlan for vlan in VLAN.objects.all()}
+        site2 = Site.objects.get(name='Site 2')
+        site3 = Site.objects.create(name='Site 3', slug='site-3', custom_field_data={
+            'multiobject_field': [vlans['VLAN 4'].pk, vlans['VLAN 5'].pk],
+        })
+        self.add_permissions('dcim.view_site')
+        values = []
+
+        class RecordingListSerializer(CustomFieldListSerializer):
+            def to_representation(self, data):
+                values.append([vlan.pk for vlan in data])
+                return super().to_representation(data)
+
+        with patch.object(VLANSerializer.Meta, 'list_serializer_class', RecordingListSerializer, create=True):
+            response = self.client.get(
+                reverse('dcim-api:site-list'), {'id': [site2.pk, site3.pk], 'fields': 'name,custom_fields'},
+                **self.header
+            )
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertEqual(values, [
+            [vlans['VLAN 3'].pk, vlans['VLAN 4'].pk],
+            [vlans['VLAN 4'].pk, vlans['VLAN 5'].pk],
+        ])
+
     def test_get_object_selection_field_representation(self):
     def test_get_object_selection_field_representation(self):
         """
         """
         Selection custom fields are rendered as an object exposing both the stored value and its
         Selection custom fields are rendered as an object exposing both the stored value and its

+ 7 - 4
netbox/ipam/api/serializers_/asns.py

@@ -1,3 +1,5 @@
+from functools import cached_property
+
 from rest_framework import serializers
 from rest_framework import serializers
 
 
 from dcim.models import Site
 from dcim.models import Site
@@ -89,11 +91,12 @@ class AvailableASNSerializer(serializers.Serializer):
     asn = serializers.IntegerField(read_only=True)
     asn = serializers.IntegerField(read_only=True)
     description = serializers.CharField(required=False)
     description = serializers.CharField(required=False)
 
 
+    @cached_property
+    def _rir_serializer(self):
+        return RIRSerializer(nested=True, context={'request': self.context['request']})
+
     def to_representation(self, asn):
     def to_representation(self, asn):
-        rir = RIRSerializer(self.context['range'].rir, nested=True, context={
-            'request': self.context['request']
-        }).data
         return {
         return {
-            'rir': rir,
+            'rir': self._rir_serializer.to_representation(self.context['range'].rir),
             'asn': asn,
             'asn': asn,
         }
         }

+ 12 - 2
netbox/ipam/api/serializers_/ip.py

@@ -1,3 +1,5 @@
+from functools import cached_property
+
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from rest_framework import serializers
 from rest_framework import serializers
 
 
@@ -131,9 +133,13 @@ class AvailablePrefixSerializer(serializers.Serializer):
     prefix = serializers.CharField(read_only=True)
     prefix = serializers.CharField(read_only=True)
     vrf = VRFSerializer(nested=True, read_only=True, allow_null=True)
     vrf = VRFSerializer(nested=True, read_only=True, allow_null=True)
 
 
+    @cached_property
+    def _vrf_serializer(self):
+        return VRFSerializer(nested=True, context={'request': self.context['request']})
+
     def to_representation(self, instance):
     def to_representation(self, instance):
         if self.context.get('vrf'):
         if self.context.get('vrf'):
-            vrf = VRFSerializer(self.context['vrf'], nested=True, context={'request': self.context['request']}).data
+            vrf = self._vrf_serializer.to_representation(self.context['vrf'])
         else:
         else:
             vrf = None
             vrf = None
         return {
         return {
@@ -242,9 +248,13 @@ class AvailableIPSerializer(serializers.Serializer):
     vrf = VRFSerializer(nested=True, read_only=True, allow_null=True)
     vrf = VRFSerializer(nested=True, read_only=True, allow_null=True)
     description = serializers.CharField(required=False)
     description = serializers.CharField(required=False)
 
 
+    @cached_property
+    def _vrf_serializer(self):
+        return VRFSerializer(nested=True, context={'request': self.context['request']})
+
     def to_representation(self, instance):
     def to_representation(self, instance):
         if self.context.get('vrf'):
         if self.context.get('vrf'):
-            vrf = VRFSerializer(self.context['vrf'], nested=True, context={'request': self.context['request']}).data
+            vrf = self._vrf_serializer.to_representation(self.context['vrf'])
         else:
         else:
             vrf = None
             vrf = None
         return {
         return {

+ 7 - 5
netbox/ipam/api/serializers_/vlans.py

@@ -1,3 +1,5 @@
+from functools import cached_property
+
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from rest_framework import serializers
 from rest_framework import serializers
 
 
@@ -85,14 +87,14 @@ class AvailableVLANSerializer(serializers.Serializer):
     vid = serializers.IntegerField(read_only=True)
     vid = serializers.IntegerField(read_only=True)
     group = VLANGroupSerializer(nested=True, read_only=True, allow_null=True)
     group = VLANGroupSerializer(nested=True, read_only=True, allow_null=True)
 
 
+    @cached_property
+    def _group_serializer(self):
+        return VLANGroupSerializer(nested=True, context={'request': self.context['request']})
+
     def to_representation(self, instance):
     def to_representation(self, instance):
         return {
         return {
             'vid': instance,
             'vid': instance,
-            'group': VLANGroupSerializer(
-                self.context['group'],
-                nested=True,
-                context={'request': self.context['request']}
-            ).data,
+            'group': self._group_serializer.to_representation(self.context['group']),
         }
         }
 
 
 
 

+ 101 - 0
netbox/ipam/tests/test_api.py

@@ -1,5 +1,6 @@
 import json
 import json
 import logging
 import logging
+from unittest.mock import patch
 
 
 from django.test import tag
 from django.test import tag
 from django.urls import reverse
 from django.urls import reverse
@@ -7,6 +8,7 @@ from netaddr import IPNetwork
 from rest_framework import status
 from rest_framework import status
 
 
 from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer, Site
 from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer, Site
+from ipam.api.serializers import RIRSerializer, VLANGroupSerializer, VRFSerializer
 from ipam.choices import *
 from ipam.choices import *
 from ipam.models import *
 from ipam.models import *
 from tenancy.models import Tenant
 from tenancy.models import Tenant
@@ -95,6 +97,28 @@ class ASNRangeTestCase(APIViewTestCases.APIViewTestCase):
         self.assertHttpStatus(response, status.HTTP_200_OK)
         self.assertHttpStatus(response, status.HTTP_200_OK)
         self.assertEqual(len(response.data), 10)
         self.assertEqual(len(response.data), 10)
 
 
+    def test_list_available_asns_reuse_rir_serializer(self):
+        """Available ASNs share one nested RIR serializer per request."""
+        rir = RIR.objects.first()
+        asnrange = ASNRange.objects.create(name='Range 1', slug='range-1', rir=rir, start=101, end=110)
+        url = reverse('ipam-api:asnrange-available-asns', kwargs={'pk': asnrange.pk})
+        self.add_permissions('ipam.view_asnrange', 'ipam.view_asn')
+
+        def get(limit, host):
+            with patch.object(RIRSerializer, '__init__', autospec=True, side_effect=RIRSerializer.__init__) as init:
+                response = self.client.get(url, {'limit': limit}, HTTP_HOST=host, **self.header)
+            self.assertHttpStatus(response, status.HTTP_200_OK)
+            return init.call_count, response.data
+
+        one, _ = get(1, 'a.example.com')
+        ten, asns = get(10, 'b.example.com')
+        self.assertEqual(one, ten)
+        self.assertEqual([asn['asn'] for asn in asns], list(range(101, 111)))
+        for asn in asns:
+            self.assertEqual(asn['rir']['id'], rir.pk)
+            self.assertTrue(asn['rir']['url'].startswith('http://b.example.com/'))
+            self.assertNotIn('custom_fields', asn['rir'])
+
     def test_create_single_available_asn(self):
     def test_create_single_available_asn(self):
         """
         """
         Test creation of the first available ASN within a range.
         Test creation of the first available ASN within a range.
@@ -532,6 +556,36 @@ class PrefixTestCase(APIViewTestCases.APIViewTestCase):
         for i, p in enumerate(response.data):
         for i, p in enumerate(response.data):
             self.assertEqual(p['prefix'], available_prefixes[i])
             self.assertEqual(p['prefix'], available_prefixes[i])
 
 
+    def test_list_available_prefixes_reuse_vrf_serializer(self):
+        """Available prefixes share one nested VRF serializer per request."""
+        vrf = VRF.objects.create(name='VRF 1')
+        parents = {
+            1: Prefix.objects.create(prefix=IPNetwork('198.51.100.0/24'), vrf=vrf),
+            3: Prefix.objects.create(prefix=IPNetwork('192.0.2.0/24'), vrf=vrf),
+        }
+        Prefix.objects.create(prefix=IPNetwork('198.51.100.0/25'), vrf=vrf)
+        Prefix.objects.create(prefix=IPNetwork('192.0.2.64/26'), vrf=vrf)
+        Prefix.objects.create(prefix=IPNetwork('192.0.2.192/27'), vrf=vrf)
+        self.add_permissions('ipam.view_prefix')
+
+        def get(size, host):
+            url = reverse('ipam-api:prefix-available-prefixes', kwargs={'pk': parents[size].pk})
+            with patch.object(VRFSerializer, '__init__', autospec=True, side_effect=VRFSerializer.__init__) as init:
+                response = self.client.get(url, HTTP_HOST=host, **self.header)
+            self.assertHttpStatus(response, status.HTTP_200_OK)
+            return init.call_count, response.data
+
+        one, _ = get(1, 'a.example.com')
+        three, prefixes = get(3, 'b.example.com')
+        self.assertEqual(one, three)
+        self.assertEqual(
+            [prefix['prefix'] for prefix in prefixes], ['192.0.2.0/26', '192.0.2.128/26', '192.0.2.224/27']
+        )
+        for prefix in prefixes:
+            self.assertEqual(prefix['vrf']['id'], vrf.pk)
+            self.assertTrue(prefix['vrf']['url'].startswith('http://b.example.com/'))
+            self.assertNotIn('custom_fields', prefix['vrf'])
+
     def test_create_single_available_prefix(self):
     def test_create_single_available_prefix(self):
         """
         """
         Test retrieval of the first available prefix within a parent prefix.
         Test retrieval of the first available prefix within a parent prefix.
@@ -652,6 +706,28 @@ class PrefixTestCase(APIViewTestCases.APIViewTestCase):
         response = self.client.get(url, **self.header)
         response = self.client.get(url, **self.header)
         self.assertEqual(len(response.data), 6)  # 8 - 2 because prefix.is_pool = False
         self.assertEqual(len(response.data), 6)  # 8 - 2 because prefix.is_pool = False
 
 
+    def test_list_available_ips_reuse_vrf_serializer(self):
+        """Available IP addresses share one nested VRF serializer per request."""
+        vrf = VRF.objects.create(name='VRF 1')
+        prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/29'), vrf=vrf, is_pool=True)
+        url = reverse('ipam-api:prefix-available-ips', kwargs={'pk': prefix.pk})
+        self.add_permissions('ipam.view_prefix', 'ipam.view_ipaddress')
+
+        def get(limit, host):
+            with patch.object(VRFSerializer, '__init__', autospec=True, side_effect=VRFSerializer.__init__) as init:
+                response = self.client.get(url, {'limit': limit}, HTTP_HOST=host, **self.header)
+            self.assertHttpStatus(response, status.HTTP_200_OK)
+            return init.call_count, response.data
+
+        one, _ = get(1, 'a.example.com')
+        eight, addresses = get(8, 'b.example.com')
+        self.assertEqual(one, eight)
+        self.assertEqual(len(addresses), 8)
+        for address in addresses:
+            self.assertEqual(address['vrf']['id'], vrf.pk)
+            self.assertTrue(address['vrf']['url'].startswith('http://b.example.com/'))
+            self.assertNotIn('custom_fields', address['vrf'])
+
     def test_create_single_available_ip(self):
     def test_create_single_available_ip(self):
         """
         """
         Test retrieval of the first available IP address within a parent prefix.
         Test retrieval of the first available IP address within a parent prefix.
@@ -1331,6 +1407,31 @@ class VLANGroupTestCase(APIViewTestCases.APIViewTestCase):
         response = self.client.get(f'{url}?limit=10', **self.header)
         response = self.client.get(f'{url}?limit=10', **self.header)
         self.assertEqual(len(response.data), 10)
         self.assertEqual(len(response.data), 10)
 
 
+    def test_list_available_vlans_reuse_group_serializer(self):
+        """Available VLANs share one nested VLAN group serializer per request."""
+        self.add_permissions('ipam.view_vlangroup', 'ipam.view_vlan')
+        vlangroup = VLANGroup.objects.create(
+            name='VLAN Group X', slug='vlan-group-x', vid_ranges=string_to_ranges('100-199')
+        )
+        url = reverse('ipam-api:vlangroup-available-vlans', kwargs={'pk': vlangroup.pk})
+
+        def get(limit, host):
+            with patch.object(
+                VLANGroupSerializer, '__init__', autospec=True, side_effect=VLANGroupSerializer.__init__
+            ) as init:
+                response = self.client.get(url, {'limit': limit}, HTTP_HOST=host, **self.header)
+            self.assertHttpStatus(response, status.HTTP_200_OK)
+            return init.call_count, response.data
+
+        one, _ = get(1, 'a.example.com')
+        ten, vlans = get(10, 'b.example.com')
+        self.assertEqual(one, ten)
+        self.assertEqual([vlan['vid'] for vlan in vlans], list(range(100, 110)))
+        for vlan in vlans:
+            self.assertEqual(vlan['group']['id'], vlangroup.pk)
+            self.assertTrue(vlan['group']['url'].startswith('http://b.example.com/'))
+            self.assertNotIn('custom_fields', vlan['group'])
+
     def test_create_single_available_vlan(self):
     def test_create_single_available_vlan(self):
         """
         """
         Test the creation of a single available VLAN.
         Test the creation of a single available VLAN.

+ 8 - 1
netbox/netbox/api/fields.py

@@ -1,3 +1,5 @@
+from functools import cached_property
+
 from django.core.exceptions import ObjectDoesNotExist
 from django.core.exceptions import ObjectDoesNotExist
 from django.db.backends.postgresql.psycopg_any import NumericRange
 from django.db.backends.postgresql.psycopg_any import NumericRange
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
@@ -145,8 +147,13 @@ class SerializedPKRelatedField(PrimaryKeyRelatedField):
 
 
         super().__init__(**kwargs)
         super().__init__(**kwargs)
 
 
+    # One per field instance, which DRF copies for every serializer instance
+    @cached_property
+    def _nested_serializer(self):
+        return self.serializer(nested=self.nested, context={'request': self.context['request']})
+
     def to_representation(self, value):
     def to_representation(self, value):
-        return self.serializer(value, nested=self.nested, context={'request': self.context['request']}).data
+        return self._nested_serializer.to_representation(value)
 
 
 
 
 @extend_schema_field(OpenApiTypes.INT64)
 @extend_schema_field(OpenApiTypes.INT64)

+ 72 - 0
netbox/netbox/tests/test_api.py

@@ -1,5 +1,6 @@
 import hashlib
 import hashlib
 import uuid
 import uuid
+from unittest.mock import patch
 
 
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import NON_FIELD_ERRORS
 from django.core.exceptions import NON_FIELD_ERRORS
@@ -15,6 +16,8 @@ from core.models import DataFile, DataSource, ObjectType
 from dcim.api.serializers import RackSerializer
 from dcim.api.serializers import RackSerializer
 from dcim.models import Device, Site
 from dcim.models import Device, Site
 from extras.models import ExportTemplate
 from extras.models import ExportTemplate
+from ipam.api.serializers import RouteTargetSerializer, VRFSerializer
+from ipam.models import VRF, RouteTarget
 from netbox.api.exceptions import QuerySetNotOrdered, SerializerNotFound
 from netbox.api.exceptions import QuerySetNotOrdered, SerializerNotFound
 from netbox.api.fields import ContentTypeField, IntegerRangeSerializer, RelatedObjectCountField
 from netbox.api.fields import ContentTypeField, IntegerRangeSerializer, RelatedObjectCountField
 from netbox.api.pagination import NetBoxPagination
 from netbox.api.pagination import NetBoxPagination
@@ -22,6 +25,7 @@ from netbox.api.serializers import ValidatedModelSerializer
 from users.models import Token
 from users.models import Token
 from utilities.api import get_serializer_for_model
 from utilities.api import get_serializer_for_model
 from utilities.testing import APITestCase
 from utilities.testing import APITestCase
+from vpn.api.serializers import L2VPNSerializer
 
 
 
 
 class AppTestCase(APITestCase):
 class AppTestCase(APITestCase):
@@ -285,6 +289,74 @@ class ContentTypeFieldTestCase(TestCase):
             field.to_internal_value(['dcim.device', 'dcim.site'])
             field.to_internal_value(['dcim.device', 'dcim.site'])
 
 
 
 
+class SerializedPKRelatedFieldTestCase(APITestCase):
+
+    @classmethod
+    def setUpTestData(cls):
+        route_targets = (
+            RouteTarget(name='65000:1'),
+            RouteTarget(name='65000:2'),
+            RouteTarget(name='65000:3'),
+        )
+        RouteTarget.objects.bulk_create(route_targets)
+
+        vrfs = (
+            VRF(name='VRF 1'),
+            VRF(name='VRF 2'),
+        )
+        VRF.objects.bulk_create(vrfs)
+        vrfs[0].import_targets.set(route_targets[:2])
+        vrfs[1].import_targets.set(route_targets[1:])
+
+    def test_to_representation_reuses_nested_serializer(self):
+        """A field builds one nested serializer for all its objects, lazily, in nested and full mode."""
+        context = {'request': RequestFactory().get('/')}
+        route_targets = RouteTarget.objects.order_by('name')
+        for serializer_class, nested in ((L2VPNSerializer, True), (VRFSerializer, False)):
+            with self.subTest(serializer=serializer_class.__name__):
+                expected = [
+                    RouteTargetSerializer(route_target, nested=nested, context=context).data
+                    for route_target in route_targets
+                ]
+                with patch.object(
+                    RouteTargetSerializer, '__init__', autospec=True, side_effect=RouteTargetSerializer.__init__
+                ) as init:
+                    field = serializer_class(context=context).fields['import_targets']
+                    self.assertEqual(field.to_representation([]), [])
+                    self.assertEqual(init.call_count, 0)
+                    self.assertEqual(field.to_representation(route_targets), expected)
+                self.assertEqual(init.call_count, 1)
+
+    def test_list_reuses_nested_serializer_per_request(self):
+        """Each list request builds its own nested serializer, also through the browsable API."""
+        self.add_permissions('ipam.view_vrf')
+        url = reverse('ipam-api:vrf-list')
+
+        def get(params, host):
+            with patch.object(
+                RouteTargetSerializer, '__init__', autospec=True, side_effect=RouteTargetSerializer.__init__
+            ) as init:
+                response = self.client.get(
+                    url, {'fields': 'name,import_targets', **params}, HTTP_HOST=host, **self.header
+                )
+            self.assertEqual(response.status_code, 200)
+            return init.call_count, response.data['results']
+
+        one, _ = get({'name': 'VRF 1'}, 'a.example.com')
+        two, vrfs = get({}, 'b.example.com')
+        self.assertEqual(one, two)
+        self.assertEqual(
+            {vrf['name']: [target['name'] for target in vrf['import_targets']] for vrf in vrfs},
+            {'VRF 1': ['65000:1', '65000:2'], 'VRF 2': ['65000:2', '65000:3']},
+        )
+        for vrf in vrfs:
+            for target in vrf['import_targets']:
+                self.assertTrue(target['url'].startswith('http://b.example.com/'))
+
+        response = self.client.get(url, {'format': 'api'}, **self.header)
+        self.assertContains(response, '65000:3')
+
+
 class ValidatedModelSerializerTestCase(TestCase):
 class ValidatedModelSerializerTestCase(TestCase):
 
 
     def test_serializers_declare_model_clean_fields(self):
     def test_serializers_declare_model_clean_fields(self):