From 1526e437f11eae6481b467419f527f877efe03e2 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 28 Jan 2026 16:06:46 -0500 Subject: [PATCH] Closes #21244: Introduce ability to omit specific fields from REST API responses (#21312) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/integrations/rest-api.md | 50 +++++++++++++++++++++++--- netbox/netbox/api/serializers/base.py | 34 ++++++++++-------- netbox/netbox/api/viewsets/__init__.py | 31 +++++++++------- netbox/utilities/api.py | 27 +++++++++----- 4 files changed, 101 insertions(+), 41 deletions(-) diff --git a/docs/integrations/rest-api.md b/docs/integrations/rest-api.md index 66a95d924..bac984a08 100644 --- a/docs/integrations/rest-api.md +++ b/docs/integrations/rest-api.md @@ -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. -### 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 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 -GET /api/ipam/prefixes/13980/?brief=1 +GET /api/ipam/prefixes/13980/?brief=true ``` ```json diff --git a/netbox/netbox/api/serializers/base.py b/netbox/netbox/api/serializers/base.py index 6cd4e5738..f60b41ae9 100644 --- a/netbox/netbox/api/serializers/base.py +++ b/netbox/netbox/api/serializers/base.py @@ -1,9 +1,8 @@ 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.utils import extend_schema_field +from rest_framework import serializers from utilities.api import get_related_object_by_attrs from .fields import NetBoxAPIHyperlinkedIdentityField, NetBoxURLHyperlinkedIdentityField @@ -19,16 +18,18 @@ class BaseModelSerializer(serializers.ModelSerializer): display_url = NetBoxURLHyperlinkedIdentityField() 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. :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 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._requested_fields = fields + self._include_fields = fields or [] + self._omit_fields = omit or [] # Disable validators for nested objects (which already exist) if self.nested: @@ -36,8 +37,8 @@ class BaseModelSerializer(serializers.ModelSerializer): # If this serializer is nested but no fields have been specified, # default to using Meta.brief_fields (if set) - if self.nested and not fields: - self._requested_fields = getattr(self.Meta, 'brief_fields', None) + if self.nested and not fields and not omit: + self._include_fields = getattr(self.Meta, 'brief_fields', None) super().__init__(*args, **kwargs) @@ -54,16 +55,19 @@ class BaseModelSerializer(serializers.ModelSerializer): @cached_property def fields(self): """ - Override the fields property to check for requested fields. If defined, - return only the applicable fields. + Override the fields property to return only specifically requested fields if needed. """ - if not self._requested_fields: - return super().fields + fields = 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 @extend_schema_field(OpenApiTypes.STR) diff --git a/netbox/netbox/api/viewsets/__init__.py b/netbox/netbox/api/viewsets/__init__.py index 6241be4cd..ea2195990 100644 --- a/netbox/netbox/api/viewsets/__init__.py +++ b/netbox/netbox/api/viewsets/__init__.py @@ -5,13 +5,13 @@ from django.core.exceptions import ObjectDoesNotExist, PermissionDenied from django.db import router, transaction from django.db.models import ProtectedError, RestrictedError 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 status from rest_framework.response import Response from rest_framework.viewsets import GenericViewSet 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.exceptions import AbortRequest from utilities.query import reapply_model_ordering @@ -59,33 +59,38 @@ class BaseViewSet(GenericViewSet): serializer_class = self.get_serializer_class() # 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) # 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) return qs def get_serializer(self, *args, **kwargs): - - # If specific fields have been requested, pass them to the serializer - if self.requested_fields: - kwargs['fields'] = self.requested_fields - + # Pass the fields/omit kwargs (if specified by the request) to the serializer + kwargs.update(**self.field_kwargs) return super().get_serializer(*args, **kwargs) @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 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 - elif self.brief: + if self.brief: serializer_class = self.get_serializer_class() - return getattr(serializer_class.Meta, 'brief_fields', None) - return None + if brief_fields := getattr(serializer_class.Meta, 'brief_fields', None): + return {'fields': brief_fields} + + return {} class NetBoxReadOnlyModelViewSet( diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index d7fa3129a..7c9fcf607 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -93,18 +93,23 @@ def 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. """ + if fields is not None and omit is not None: + raise TypeError("Cannot specify both 'fields' and 'omit' parameters.") + model = serializer_class.Meta.model # If fields are not specified, default to all - if not fields_to_include: - fields_to_include = serializer_class.Meta.fields + fields_to_include = fields or serializer_class.Meta.fields + fields_to_omit = omit or [] prefetch_fields = [] for field_name in fields_to_include: + if field_name in fields_to_omit: + continue serializer_field = serializer_class._declared_fields.get(field_name) # 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 -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. """ - annotations = {} - - # If specific fields are not specified, default to all - if not fields_to_include: - fields_to_include = serializer_class.Meta.fields + if fields is not None and omit is not None: + raise TypeError("Cannot specify both 'fields' and 'omit' parameters.") 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(): + if field_name in fields_to_omit: + continue if field_name in fields_to_include and type(field) is RelatedObjectCountField: related_field = getattr(model, field.relation).field annotations[field_name] = count_related(related_field.model, related_field.name)