diff --git a/netbox/netbox/api/serializers/base.py b/netbox/netbox/api/serializers/base.py index 0f933ed5f..62f2cba5e 100644 --- a/netbox/netbox/api/serializers/base.py +++ b/netbox/netbox/api/serializers/base.py @@ -4,6 +4,7 @@ from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import extend_schema_field from rest_framework import serializers +from netbox.api.fields import RelatedObjectCountField from utilities.api import get_related_object_by_attrs from .fields import NetBoxAPIHyperlinkedIdentityField, NetBoxURLHyperlinkedIdentityField @@ -69,6 +70,15 @@ class BaseModelSerializer(serializers.ModelSerializer): for field_name in set(self._omit_fields): fields.pop(field_name, None) + # Related object counts are populated by annotations applied to the viewset's queryset, but these + # annotations are not applied when the object is represented as a nested (brief) related object. Omit + # these fields when serializing a nested object to avoid advertising fields that will never be populated + # (and which would otherwise be declared as required in the generated OpenAPI schema). See #22154. + if self.nested: + for field_name, field in list(fields.items()): + if isinstance(field, RelatedObjectCountField): + fields.pop(field_name) + return fields @extend_schema_field(OpenApiTypes.STR) diff --git a/netbox/netbox/tests/test_api.py b/netbox/netbox/tests/test_api.py index e298e4647..06f6b273b 100644 --- a/netbox/netbox/tests/test_api.py +++ b/netbox/netbox/tests/test_api.py @@ -6,8 +6,9 @@ from django.urls import reverse from rest_framework.exceptions import ValidationError from rest_framework.request import Request +from dcim.api.serializers import RackSerializer from netbox.api.exceptions import QuerySetNotOrdered -from netbox.api.fields import IntegerRangeSerializer +from netbox.api.fields import IntegerRangeSerializer, RelatedObjectCountField from netbox.api.pagination import NetBoxPagination from users.models import Token from utilities.testing import APITestCase @@ -48,6 +49,29 @@ class AppTestCase(APITestCase): self.assertEqual(response.data['id'], self.user.pk) +class RelatedObjectCountFieldTestCase(TestCase): + """ + RelatedObjectCountFields are populated by annotations applied to a viewset's queryset, which are only + added when serializing an object via its own endpoint (including ?brief=1). They are never annotated when + the object is rendered as a nested related object, so they must be omitted from nested representations to + keep the generated OpenAPI schema honest. See #22154. + """ + def test_count_field_omitted_when_nested(self): + """A nested serializer must drop RelatedObjectCountFields (e.g. RackSerializer.device_count).""" + serializer = RackSerializer(nested=True) + count_fields = [ + name for name, field in serializer.fields.items() if isinstance(field, RelatedObjectCountField) + ] + self.assertEqual(count_fields, []) + self.assertNotIn('device_count', serializer.fields) + + def test_count_field_retained_in_brief_mode(self): + """?brief=1 (fields=brief_fields, not nested) must retain RelatedObjectCountFields.""" + serializer = RackSerializer(fields=RackSerializer.Meta.brief_fields) + self.assertIn('device_count', serializer.fields) + self.assertIsInstance(serializer.fields['device_count'], RelatedObjectCountField) + + class OptionalLimitOffsetPaginationTestCase(TestCase): def setUp(self):