Explorar o código

Closes #22989: Reference brief components for nested SerializedPKRelatedField

FixSerializedPKRelatedField passed the serializer class to resolve_serializer(),
which instantiates it with no arguments. The field's nested setting was therefore
lost, and the generated response schema referenced the complete component (with
the complete field set) even where the field renders a brief representation.

Resolve an instance carrying the field's nested setting instead. Request schemas
are unaffected and continue to accept integer primary keys.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Jeremy Stretch hai 1 mes
pai
achega
2893928662
Modificáronse 2 ficheiros con 127 adicións e 2 borrados
  1. 13 2
      netbox/core/api/schema.py
  2. 114 0
      netbox/core/tests/test_openapi_schema.py

+ 13 - 2
netbox/core/api/schema.py

@@ -1,3 +1,4 @@
+import inspect
 import re
 import typing
 from collections import OrderedDict
@@ -17,7 +18,7 @@ from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.utils import Direction, OpenApiParameter
 
 from netbox.api.fields import ChoiceField
-from netbox.api.serializers import WritableNestedSerializer
+from netbox.api.serializers import BaseModelSerializer, WritableNestedSerializer
 from netbox.api.viewsets import NetBoxModelViewSet
 
 # see netbox.api.routers.NetBoxRouter
@@ -389,7 +390,17 @@ class FixSerializedPKRelatedField(OpenApiSerializerFieldExtension):
 
     def map_serializer_field(self, auto_schema, direction):
         if direction == "response":
-            component = auto_schema.resolve_serializer(self.target.serializer, direction)
+            # Resolve an instance of the serializer carrying the field's nested setting, so that the brief
+            # component is referenced wherever the field renders a brief representation. Serializers which
+            # don't derive from BaseModelSerializer (e.g. a plain ModelSerializer employed by a plugin) don't
+            # accept the nested kwarg, so instantiate those without it.
+            serializer = self.target.serializer
+            if inspect.isclass(serializer):
+                if issubclass(serializer, BaseModelSerializer):
+                    serializer = serializer(nested=self.target.nested)
+                else:
+                    serializer = serializer()
+            component = auto_schema.resolve_serializer(serializer, direction)
             return component.ref if component else None
         return build_basic_type(OpenApiTypes.INT)
 

+ 114 - 0
netbox/core/tests/test_openapi_schema.py

@@ -6,6 +6,12 @@ Refs: #20638
 import json
 
 from django.test import TestCase
+from rest_framework import serializers
+
+from core.api.schema import FixSerializedPKRelatedField
+from dcim.api.serializers import SiteSerializer
+from dcim.models import Site
+from netbox.api.fields import SerializedPKRelatedField
 
 
 class OpenAPISchemaTestCase(TestCase):
@@ -107,3 +113,111 @@ class OpenAPISchemaTestCase(TestCase):
         self.assertNotIn('oneOf', request_schema, "DELETE should NOT have oneOf")
         self.assertEqual(request_schema['type'], 'array', "DELETE should require array")
         self.assertIn('items', request_schema, "DELETE array should have items")
+
+    def test_nested_related_fields_reference_brief_components(self):
+        """
+        A SerializedPKRelatedField declared with nested=True must reference the brief component in
+        response schemas, as that is what the API returns.
+
+        Refs: #22989
+        """
+        components = self.schema['components']['schemas']
+
+        for component, field, ref in (
+            ('Site', 'asns', 'BriefASN'),
+            ('ConfigContext', 'sites', 'BriefSite'),
+            ('ASN', 'sites', 'BriefASNSite'),
+        ):
+            with self.subTest(component=component, field=field):
+                self.assertEqual(
+                    components[component]['properties'][field]['items']['$ref'],
+                    f'#/components/schemas/{ref}'
+                )
+
+        # The brief component must advertise only the serializer's brief fields
+        self.assertEqual(
+            set(components['BriefASN']['properties']),
+            {'id', 'url', 'display', 'asn', 'description'}
+        )
+
+    def test_non_nested_related_fields_reference_full_components(self):
+        """
+        A SerializedPKRelatedField declared without nested=True must continue to reference the
+        complete component.
+
+        Refs: #22989
+        """
+        components = self.schema['components']['schemas']
+
+        for field in ('import_targets', 'export_targets'):
+            with self.subTest(field=field):
+                self.assertEqual(
+                    components['VRF']['properties'][field]['items']['$ref'],
+                    '#/components/schemas/RouteTarget'
+                )
+
+    def test_nested_related_fields_accept_pks_on_write(self):
+        """
+        Request schemas for a SerializedPKRelatedField must continue to accept an array of integer
+        primary keys.
+
+        Refs: #22989
+        """
+        components = self.schema['components']['schemas']
+
+        for component, field in (
+            ('SiteRequest', 'asns'),
+            ('ConfigContextRequest', 'sites'),
+            ('ASNRequest', 'sites'),
+        ):
+            with self.subTest(component=component, field=field):
+                self.assertEqual(components[component]['properties'][field]['items']['type'], 'integer')
+
+
+class SerializedPKRelatedFieldSchemaTestCase(TestCase):
+    """Tests for the schema extension which maps SerializedPKRelatedField."""
+
+    class PlainSerializer(serializers.ModelSerializer):
+        """A serializer which does not derive from BaseModelSerializer, as a plugin might employ."""
+
+        class Meta:
+            model = Site
+            fields = ('id', 'name')
+
+    def resolve_serializer(self, field):
+        """Invoke the schema extension for a field, returning the serializer instance it resolved."""
+        resolved = []
+
+        class DummyAutoSchema:
+            def resolve_serializer(self, serializer, direction):
+                resolved.append(serializer)
+
+        FixSerializedPKRelatedField(field).map_serializer_field(DummyAutoSchema(), 'response')
+        return resolved[0]
+
+    def test_nested_flag_is_passed_to_netbox_serializers(self):
+        """
+        A serializer derived from BaseModelSerializer must be instantiated with the field's nested setting.
+
+        Refs: #22989
+        """
+        for nested in (True, False):
+            with self.subTest(nested=nested):
+                field = SerializedPKRelatedField(
+                    serializer=SiteSerializer,
+                    queryset=Site.objects.all(),
+                    nested=nested
+                )
+                serializer = self.resolve_serializer(field)
+                self.assertIsInstance(serializer, SiteSerializer)
+                self.assertEqual(serializer.nested, nested)
+
+    def test_serializer_without_nested_support(self):
+        """
+        A serializer which does not accept the nested kwarg must still resolve, rather than breaking
+        generation of the entire schema.
+
+        Refs: #22989
+        """
+        field = SerializedPKRelatedField(serializer=self.PlainSerializer, queryset=Site.objects.all())
+        self.assertIsInstance(self.resolve_serializer(field), self.PlainSerializer)