Ver Fonte

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

Martin Hauser há 16 horas atrás
pai
commit
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 rest_framework import serializers
 
@@ -37,6 +39,10 @@ class JobSerializer(BaseModelSerializer):
         ]
         brief_fields = ('url', 'created', 'completed', 'user', 'status')
 
+    @cached_property
+    def _object_serializers(self):
+        return {}
+
     @extend_schema_field(serializers.JSONField(allow_null=True))
     def get_object(self, obj):
         """
@@ -45,8 +51,11 @@ class JobSerializer(BaseModelSerializer):
         if obj.object is None:
             return None
         try:
-            serializer = get_serializer_for_model(obj.object)
+            serializer_class = get_serializer_for_model(obj.object)
         except SerializerNotFound:
             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.registry import FailedJobRegistry, StartedJobRegistry
 
+from core.api.serializers import DataSourceSerializer
+from dcim.models import Site
 from users.constants import TOKEN_PREFIX
 from users.models import ObjectPermission, Token
 from utilities.testing import APITestCase, APIViewTestCases, GraphQLQueryTest, TestCase, create_tags
@@ -339,6 +341,41 @@ class JobTestCase(
         self.assertHttpStatus(response, status.HTTP_200_OK)
         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):
     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.utils import extend_schema_field
 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 None
 
+    @cached_property
+    def _connected_endpoint_serializers(self):
+        return {}
+
     @extend_schema_field(serializers.ListField(allow_null=True))
     def get_connected_endpoints(self, obj):
         """
         Return the appropriate serializer for the type of connected object.
         """
         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
 
     @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.utils import extend_schema_field
 from rest_framework import serializers
@@ -94,6 +96,10 @@ class CablePathSerializer(serializers.ModelSerializer):
         model = CablePath
         fields = ['id', 'path', 'is_active', 'is_complete', 'is_split']
 
+    @cached_property
+    def _node_serializers(self):
+        return {}
+
     @extend_schema_field(serializers.ListField)
     def get_path(self, obj):
         ret = []
@@ -101,9 +107,12 @@ class CablePathSerializer(serializers.ModelSerializer):
             if not nodes:
                 # The path contains an invalid object
                 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
 
 
@@ -128,6 +137,10 @@ class CabledObjectSerializer(serializers.ModelSerializer):
 
         return None
 
+    @cached_property
+    def _link_peer_serializers(self):
+        return {}
+
     @extend_schema_field(serializers.ListField)
     def get_link_peers(self, obj):
         """
@@ -137,9 +150,12 @@ class CabledObjectSerializer(serializers.ModelSerializer):
             return []
 
         # 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)
     def get__occupied(self, obj):

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

@@ -1,4 +1,5 @@
 import decimal
+from functools import cached_property
 
 from django.contrib.contenttypes.models import ContentType
 from django.utils.translation import gettext as _
@@ -116,15 +117,22 @@ class DeviceSerializer(PrimaryModelSerializer):
         ]
         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))
     def get_parent_device(self, obj):
         try:
             device_bay = obj.parent_bay
         except DeviceBay.DoesNotExist:
             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
 
     @extend_schema_field(serializers.JSONField(allow_null=True))

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

@@ -1,4 +1,5 @@
 import json
+from unittest.mock import patch
 
 from django.conf import settings
 from django.contrib.contenttypes.models import ContentType
@@ -11,6 +12,8 @@ from rest_framework import status
 
 from core.choices import ObjectChangeActionChoices
 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.constants import *
 from dcim.graphql.types import _CABLE_TERMINATION_MODELS
@@ -2658,6 +2661,54 @@ class DeviceTestCase(APIViewTestCases.APIViewTestCase):
 
         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):
         configtemplate = ConfigTemplate.objects.create(
             name='Config Template 1',
@@ -3888,6 +3939,40 @@ class InterfaceTestCase(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTest
         # Tagged-all mode, qinq service vlan
         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):
         """
         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)
 
+    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):
     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.utils.translation import gettext as _
 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)
         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):
-        # TODO: Fix circular import
-        from utilities.api import get_serializer_for_model
         data = {}
         cache = self.parent.context.get('cf_object_cache')
 
@@ -71,11 +86,9 @@ class CustomFieldsDataField(Field):
                 value = cf.deserialize(obj.get(cf.name))
 
             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:
-                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):
                 value = cf.resolve_selection_value(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 rest_framework import serializers
 
@@ -54,15 +56,21 @@ class TaggedItemSerializer(BaseModelSerializer):
         ]
         brief_fields = ('id', 'url', 'display', 'object_type', 'object_id', 'object', 'tag')
 
+    @cached_property
+    def _object_serializers(self):
+        return {}
+
     @extend_schema_field(serializers.JSONField())
     def get_object(self, obj):
         """
         Serialize a nested representation of the tagged object.
         """
         try:
-            serializer = get_serializer_for_model(obj.content_object)
+            serializer_class = get_serializer_for_model(obj.content_object)
         except SerializerNotFound:
             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.events import *
 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 extras.api.serializers import EventRuleSerializer
 from extras.choices import *
@@ -1321,6 +1322,32 @@ class TaggedItemTestCase(
         sites[1].tags.set([tags[1], tags[2]])
         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)
 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.models import Manufacturer, Rack, Site
 from dcim.tables import SiteTable
+from extras.api.customfields import CustomFieldListSerializer
 from extras.choices import *
 from extras.constants import CUSTOMFIELD_JOB_TIMEOUT
 from extras.filters import MissingKeyAwareFilterMixin, missing_key_aware_filter_factory
@@ -32,6 +33,7 @@ from extras.jobs import (
     purge_custom_field,
 )
 from extras.models import CustomField, CustomFieldChoiceSet
+from ipam.api.serializers import VLANSerializer
 from ipam.models import VLAN
 from netbox.choices import CSVDelimiterChoices, ImportFormatChoices
 from netbox.context import query_cache
@@ -1488,6 +1490,67 @@ class CustomFieldAPITestCase(APITestCase):
             [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):
         """
         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 dcim.models import Site
@@ -89,11 +91,12 @@ class AvailableASNSerializer(serializers.Serializer):
     asn = serializers.IntegerField(read_only=True)
     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):
-        rir = RIRSerializer(self.context['range'].rir, nested=True, context={
-            'request': self.context['request']
-        }).data
         return {
-            'rir': rir,
+            'rir': self._rir_serializer.to_representation(self.context['range'].rir),
             '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 rest_framework import serializers
 
@@ -131,9 +133,13 @@ class AvailablePrefixSerializer(serializers.Serializer):
     prefix = serializers.CharField(read_only=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):
         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:
             vrf = None
         return {
@@ -242,9 +248,13 @@ class AvailableIPSerializer(serializers.Serializer):
     vrf = VRFSerializer(nested=True, read_only=True, allow_null=True)
     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):
         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:
             vrf = None
         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 rest_framework import serializers
 
@@ -85,14 +87,14 @@ class AvailableVLANSerializer(serializers.Serializer):
     vid = serializers.IntegerField(read_only=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):
         return {
             '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 logging
+from unittest.mock import patch
 
 from django.test import tag
 from django.urls import reverse
@@ -7,6 +8,7 @@ from netaddr import IPNetwork
 from rest_framework import status
 
 from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer, Site
+from ipam.api.serializers import RIRSerializer, VLANGroupSerializer, VRFSerializer
 from ipam.choices import *
 from ipam.models import *
 from tenancy.models import Tenant
@@ -95,6 +97,28 @@ class ASNRangeTestCase(APIViewTestCases.APIViewTestCase):
         self.assertHttpStatus(response, status.HTTP_200_OK)
         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):
         """
         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):
             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):
         """
         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)
         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):
         """
         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)
         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):
         """
         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.db.backends.postgresql.psycopg_any import NumericRange
 from django.utils.translation import gettext as _
@@ -145,8 +147,13 @@ class SerializedPKRelatedField(PrimaryKeyRelatedField):
 
         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):
-        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)

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

@@ -1,5 +1,6 @@
 import hashlib
 import uuid
+from unittest.mock import patch
 
 from django.contrib.contenttypes.models import ContentType
 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.models import Device, Site
 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.fields import ContentTypeField, IntegerRangeSerializer, RelatedObjectCountField
 from netbox.api.pagination import NetBoxPagination
@@ -22,6 +25,7 @@ from netbox.api.serializers import ValidatedModelSerializer
 from users.models import Token
 from utilities.api import get_serializer_for_model
 from utilities.testing import APITestCase
+from vpn.api.serializers import L2VPNSerializer
 
 
 class AppTestCase(APITestCase):
@@ -285,6 +289,74 @@ class ContentTypeFieldTestCase(TestCase):
             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):
 
     def test_serializers_declare_model_clean_fields(self):