Closes #21244: Introduce ability to omit specific fields from REST API responses (#21312)
CI / build (20.x, 3.12) (push) Failing after 14s
CI / build (20.x, 3.13) (push) Failing after 10s
CI / build (20.x, 3.14) (push) Failing after 11s
CodeQL / Analyze (actions) (push) Failing after 21s
CodeQL / Analyze (javascript-typescript) (push) Failing after 17s
CodeQL / Analyze (python) (push) Failing after 17s

Introduce support for omitting specific serializer fields via an
`omit` parameter, acting as the inverse of `fields`.
Wire it through the API viewset and queryset optimization helpers
so omitted fields don’t trigger unnecessary annotations/prefetches,
and document the new behavior.
This commit is contained in:
Jeremy Stretch
2026-01-28 16:06:46 -05:00
committed by GitHub
parent 0b507eb207
commit 1526e437f1
4 changed files with 101 additions and 41 deletions
+46 -4
View File
@@ -215,9 +215,51 @@ http://netbox/api/ipam/ip-addresses/ \
If we wanted to assign this IP address to a virtual machine interface instead, we would have set `assigned_object_type` to `virtualization.vminterface` and updated the object ID appropriately. If we wanted to assign this IP address to a virtual machine interface instead, we would have set `assigned_object_type` to `virtualization.vminterface` and updated the object ID appropriately.
### Brief Format ### Specifying Fields
Most API endpoints support an optional "brief" format, which returns only a minimal representation of each object in the response. This is useful when you need only a list of available objects without any related data, such as when populating a drop-down list in a form. As an example, the default (complete) format of a prefix looks like this: A REST API response will include all available fields for the object type by default. If you wish to return only a subset of the available fields, you can append `?fields=` to the URL followed by a comma-separated list of field names. For example, the following request will return only the `id`, `name`, `status`, and `region` fields for each site in the response.
```
GET /api/dcim/sites/?fields=id,name,status,region
```
```json
{
"id": 1,
"name": "DM-NYC",
"status": {
"value": "active",
"label": "Active"
},
"region": {
"id": 43,
"url": "http://netbox:8000/api/dcim/regions/43/",
"display": "New York",
"name": "New York",
"slug": "us-ny",
"description": "",
"site_count": 0,
"_depth": 2
}
}
```
Similarly, you can opt to omit only specific fields by passing the `omit` parameter:
```
GET /api/dcim/sites/?omit=circuit_count,device_count,virtualmachine_count
```
!!! note "The `omit` parameter was introduced in NetBox v4.5.2."
Strategic use of the `fields` and `omit` parameters can drastically improve REST API performance, as the exclusion of fields which reference related objects reduces the number and complexity of underlying database queries needed to generate the response.
!!! note
The `fields` and `omit` parameters should be considered mutually exclusive. If both are passed, `fields` takes precedence.
#### Brief Format
Most API endpoints support an optional "brief" format, which returns only a minimal representation of each object in the response. This is useful when you need only a list of available objects without any related data, such as when populating a drop-down list in a form. It's also more convenient than listing out individual fields via the `fields` or `omit` parameters. As an example, the default (complete) format of a prefix looks like this:
```no-highlight ```no-highlight
GET /api/ipam/prefixes/13980/ GET /api/ipam/prefixes/13980/
@@ -270,10 +312,10 @@ GET /api/ipam/prefixes/13980/
} }
``` ```
The brief format is much more terse: The brief format includes only a few fields:
```no-highlight ```no-highlight
GET /api/ipam/prefixes/13980/?brief=1 GET /api/ipam/prefixes/13980/?brief=true
``` ```
```json ```json
+19 -15
View File
@@ -1,9 +1,8 @@
from functools import cached_property from functools import cached_property
from rest_framework import serializers
from rest_framework.utils.serializer_helpers import BindingDict
from drf_spectacular.utils import extend_schema_field
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from utilities.api import get_related_object_by_attrs from utilities.api import get_related_object_by_attrs
from .fields import NetBoxAPIHyperlinkedIdentityField, NetBoxURLHyperlinkedIdentityField from .fields import NetBoxAPIHyperlinkedIdentityField, NetBoxURLHyperlinkedIdentityField
@@ -19,16 +18,18 @@ class BaseModelSerializer(serializers.ModelSerializer):
display_url = NetBoxURLHyperlinkedIdentityField() display_url = NetBoxURLHyperlinkedIdentityField()
display = serializers.SerializerMethodField(read_only=True) display = serializers.SerializerMethodField(read_only=True)
def __init__(self, *args, nested=False, fields=None, **kwargs): def __init__(self, *args, nested=False, fields=None, omit=None, **kwargs):
""" """
Extends the base __init__() method to support dynamic fields. Extends the base __init__() method to support dynamic fields.
:param nested: Set to True if this serializer is being employed within a parent serializer :param nested: Set to True if this serializer is being employed within a parent serializer
:param fields: An iterable of fields to include when rendering the serialized object, If nested is :param fields: An iterable of fields to include when rendering the serialized object, If nested is
True but no fields are specified, Meta.brief_fields will be used. True but no fields are specified, Meta.brief_fields will be used.
:param omit: An iterable of fields to omit from the serialized object
""" """
self.nested = nested self.nested = nested
self._requested_fields = fields self._include_fields = fields or []
self._omit_fields = omit or []
# Disable validators for nested objects (which already exist) # Disable validators for nested objects (which already exist)
if self.nested: if self.nested:
@@ -36,8 +37,8 @@ class BaseModelSerializer(serializers.ModelSerializer):
# If this serializer is nested but no fields have been specified, # If this serializer is nested but no fields have been specified,
# default to using Meta.brief_fields (if set) # default to using Meta.brief_fields (if set)
if self.nested and not fields: if self.nested and not fields and not omit:
self._requested_fields = getattr(self.Meta, 'brief_fields', None) self._include_fields = getattr(self.Meta, 'brief_fields', None)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@@ -54,16 +55,19 @@ class BaseModelSerializer(serializers.ModelSerializer):
@cached_property @cached_property
def fields(self): def fields(self):
""" """
Override the fields property to check for requested fields. If defined, Override the fields property to return only specifically requested fields if needed.
return only the applicable fields.
""" """
if not self._requested_fields: fields = super().fields
return super().fields
# Include only requested fields
if self._include_fields:
for field_name in set(fields) - set(self._include_fields):
fields.pop(field_name, None)
# Remove omitted fields
for field_name in set(self._omit_fields):
fields.pop(field_name, None)
fields = BindingDict(self)
for key, value in self.get_fields().items():
if key in self._requested_fields:
fields[key] = value
return fields return fields
@extend_schema_field(OpenApiTypes.STR) @extend_schema_field(OpenApiTypes.STR)
+18 -13
View File
@@ -5,13 +5,13 @@ from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
from django.db import router, transaction from django.db import router, transaction
from django.db.models import ProtectedError, RestrictedError from django.db.models import ProtectedError, RestrictedError
from django_pglocks import advisory_lock from django_pglocks import advisory_lock
from netbox.constants import ADVISORY_LOCK_KEYS
from rest_framework import mixins as drf_mixins from rest_framework import mixins as drf_mixins
from rest_framework import status from rest_framework import status
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.viewsets import GenericViewSet from rest_framework.viewsets import GenericViewSet
from netbox.api.serializers.features import ChangeLogMessageSerializer from netbox.api.serializers.features import ChangeLogMessageSerializer
from netbox.constants import ADVISORY_LOCK_KEYS
from utilities.api import get_annotations_for_serializer, get_prefetches_for_serializer from utilities.api import get_annotations_for_serializer, get_prefetches_for_serializer
from utilities.exceptions import AbortRequest from utilities.exceptions import AbortRequest
from utilities.query import reapply_model_ordering from utilities.query import reapply_model_ordering
@@ -59,33 +59,38 @@ class BaseViewSet(GenericViewSet):
serializer_class = self.get_serializer_class() serializer_class = self.get_serializer_class()
# Dynamically resolve prefetches for included serializer fields and attach them to the queryset # Dynamically resolve prefetches for included serializer fields and attach them to the queryset
if prefetch := get_prefetches_for_serializer(serializer_class, fields_to_include=self.requested_fields): if prefetch := get_prefetches_for_serializer(serializer_class, **self.field_kwargs):
qs = qs.prefetch_related(*prefetch) qs = qs.prefetch_related(*prefetch)
# Dynamically resolve annotations for RelatedObjectCountFields on the serializer and attach them to the queryset # Dynamically resolve annotations for RelatedObjectCountFields on the serializer and attach them to the queryset
if annotations := get_annotations_for_serializer(serializer_class, fields_to_include=self.requested_fields): if annotations := get_annotations_for_serializer(serializer_class, **self.field_kwargs):
qs = qs.annotate(**annotations) qs = qs.annotate(**annotations)
return qs return qs
def get_serializer(self, *args, **kwargs): def get_serializer(self, *args, **kwargs):
# Pass the fields/omit kwargs (if specified by the request) to the serializer
# If specific fields have been requested, pass them to the serializer kwargs.update(**self.field_kwargs)
if self.requested_fields:
kwargs['fields'] = self.requested_fields
return super().get_serializer(*args, **kwargs) return super().get_serializer(*args, **kwargs)
@cached_property @cached_property
def requested_fields(self): def field_kwargs(self):
"""Return a dictionary of keyword arguments to be passed when instantiating the serializer."""
# An explicit list of fields was requested # An explicit list of fields was requested
if requested_fields := self.request.query_params.get('fields'): if requested_fields := self.request.query_params.get('fields'):
return requested_fields.split(',') return {'fields': requested_fields.split(',')}
# An explicit list of fields to omit was requested
if omit_fields := self.request.query_params.get('omit'):
return {'omit': omit_fields.split(',')}
# Brief mode has been enabled for this request # Brief mode has been enabled for this request
elif self.brief: if self.brief:
serializer_class = self.get_serializer_class() serializer_class = self.get_serializer_class()
return getattr(serializer_class.Meta, 'brief_fields', None) if brief_fields := getattr(serializer_class.Meta, 'brief_fields', None):
return None return {'fields': brief_fields}
return {}
class NetBoxReadOnlyModelViewSet( class NetBoxReadOnlyModelViewSet(
+18 -9
View File
@@ -93,18 +93,23 @@ def get_view_name(view):
return drf_get_view_name(view) return drf_get_view_name(view)
def get_prefetches_for_serializer(serializer_class, fields_to_include=None): def get_prefetches_for_serializer(serializer_class, fields=None, omit=None):
""" """
Compile and return a list of fields which should be prefetched on the queryset for a serializer. Compile and return a list of fields which should be prefetched on the queryset for a serializer.
""" """
if fields is not None and omit is not None:
raise TypeError("Cannot specify both 'fields' and 'omit' parameters.")
model = serializer_class.Meta.model model = serializer_class.Meta.model
# If fields are not specified, default to all # If fields are not specified, default to all
if not fields_to_include: fields_to_include = fields or serializer_class.Meta.fields
fields_to_include = serializer_class.Meta.fields fields_to_omit = omit or []
prefetch_fields = [] prefetch_fields = []
for field_name in fields_to_include: for field_name in fields_to_include:
if field_name in fields_to_omit:
continue
serializer_field = serializer_class._declared_fields.get(field_name) serializer_field = serializer_class._declared_fields.get(field_name)
# Determine the name of the model field referenced by the serializer field # Determine the name of the model field referenced by the serializer field
@@ -132,19 +137,23 @@ def get_prefetches_for_serializer(serializer_class, fields_to_include=None):
return prefetch_fields return prefetch_fields
def get_annotations_for_serializer(serializer_class, fields_to_include=None): def get_annotations_for_serializer(serializer_class, fields=None, omit=None):
""" """
Return a mapping of field names to annotations to be applied to the queryset for a serializer. Return a mapping of field names to annotations to be applied to the queryset for a serializer.
""" """
annotations = {} if fields is not None and omit is not None:
raise TypeError("Cannot specify both 'fields' and 'omit' parameters.")
# If specific fields are not specified, default to all
if not fields_to_include:
fields_to_include = serializer_class.Meta.fields
model = serializer_class.Meta.model model = serializer_class.Meta.model
# If fields are not specified, default to all
fields_to_include = fields or serializer_class.Meta.fields
fields_to_omit = omit or []
annotations = {}
for field_name, field in serializer_class._declared_fields.items(): for field_name, field in serializer_class._declared_fields.items():
if field_name in fields_to_omit:
continue
if field_name in fields_to_include and type(field) is RelatedObjectCountField: if field_name in fields_to_include and type(field) is RelatedObjectCountField:
related_field = getattr(model, field.relation).field related_field = getattr(model, field.relation).field
annotations[field_name] = count_related(related_field.model, related_field.name) annotations[field_name] = count_related(related_field.model, related_field.name)