瀏覽代碼

Closes #21244: Introduce ability to omit specific fields from REST API responses (#21312)

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.
Jeremy Stretch 2 周之前
父節點
當前提交
1526e437f1
共有 4 個文件被更改,包括 101 次插入41 次删除
  1. 46 4
      docs/integrations/rest-api.md
  2. 19 15
      netbox/netbox/api/serializers/base.py
  3. 18 13
      netbox/netbox/api/viewsets/__init__.py
  4. 18 9
      netbox/utilities/api.py

+ 46 - 4
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

+ 19 - 15
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)

+ 18 - 13
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(

+ 18 - 9
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)