From ecd0c5655454d286a132b1b46dd98f7d0c45d219 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Thu, 30 Mar 2023 11:32:59 -0700 Subject: [PATCH] Closes #9608: Move from drf-yasg to spectacular Co-authored-by: arthanson Co-authored-by: jeremystretch --- base_requirements.txt | 6 +- netbox/circuits/api/nested_serializers.py | 8 + netbox/circuits/api/serializers.py | 8 +- netbox/core/api/schema.py | 224 ++++++++++++++++++ netbox/core/apps.py | 1 + netbox/dcim/api/nested_serializers.py | 39 ++- netbox/dcim/api/serializers.py | 100 ++++---- netbox/dcim/api/views.py | 27 +-- netbox/dcim/tests/test_api.py | 2 +- netbox/extras/api/customfields.py | 3 + netbox/extras/api/serializers.py | 12 +- netbox/extras/api/views.py | 4 +- netbox/extras/plugins/views.py | 9 +- netbox/ipam/api/nested_serializers.py | 13 + netbox/ipam/api/serializers.py | 20 +- netbox/ipam/api/views.py | 54 +++-- netbox/ipam/filtersets.py | 2 - netbox/netbox/api/fields.py | 4 + netbox/netbox/api/serializers/base.py | 3 + netbox/netbox/api/serializers/generic.py | 4 +- netbox/netbox/api/views.py | 7 +- netbox/netbox/settings.py | 57 +---- netbox/netbox/urls.py | 24 +- netbox/tenancy/api/nested_serializers.py | 7 + netbox/tenancy/api/serializers.py | 7 +- netbox/users/api/nested_serializers.py | 8 +- netbox/users/api/serializers.py | 3 + netbox/users/api/views.py | 11 +- netbox/utilities/custom_inspectors.py | 142 ----------- netbox/utilities/filters.py | 10 + netbox/utilities/tests/test_api.py | 8 +- .../virtualization/api/nested_serializers.py | 10 + netbox/virtualization/api/serializers.py | 9 +- netbox/wireless/api/nested_serializers.py | 4 + requirements.txt | 2 +- 35 files changed, 513 insertions(+), 339 deletions(-) create mode 100644 netbox/core/api/schema.py delete mode 100644 netbox/utilities/custom_inspectors.py diff --git a/base_requirements.txt b/base_requirements.txt index 4a7013a977..6e94f4daf0 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -66,9 +66,9 @@ django-timezone-field # https://github.com/encode/django-rest-framework djangorestframework -# Swagger/OpenAPI schema generation for REST APIs -# https://github.com/axnsan12/drf-yasg -drf-yasg[validation] +# Sane and flexible OpenAPI 3 schema generation for Django REST framework. +# https://github.com/tfranzel/drf-spectacular +drf-spectacular # RSS feed parser # https://github.com/kurtmckee/feedparser diff --git a/netbox/circuits/api/nested_serializers.py b/netbox/circuits/api/nested_serializers.py index 0f19ccffc7..c7aa583fae 100644 --- a/netbox/circuits/api/nested_serializers.py +++ b/netbox/circuits/api/nested_serializers.py @@ -1,3 +1,5 @@ +from drf_spectacular.utils import extend_schema_field, extend_schema_serializer +from drf_spectacular.types import OpenApiTypes from rest_framework import serializers from circuits.models import * @@ -29,6 +31,9 @@ class Meta: # Providers # +@extend_schema_serializer( + exclude_fields=('circuit_count',), +) class NestedProviderSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail') circuit_count = serializers.IntegerField(read_only=True) @@ -54,6 +59,9 @@ class Meta: # Circuits # +@extend_schema_serializer( + exclude_fields=('circuit_count',), +) class NestedCircuitTypeSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail') circuit_count = serializers.IntegerField(read_only=True) diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index 1ec8913c1b..5635b6730a 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -92,8 +92,8 @@ class Meta: class CircuitCircuitTerminationSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail') - site = NestedSiteSerializer() - provider_network = NestedProviderNetworkSerializer() + site = NestedSiteSerializer(allow_null=True) + provider_network = NestedProviderNetworkSerializer(allow_null=True) class Meta: model = CircuitTermination @@ -110,8 +110,8 @@ class CircuitSerializer(NetBoxModelSerializer): status = ChoiceField(choices=CircuitStatusChoices, required=False) type = NestedCircuitTypeSerializer() tenant = NestedTenantSerializer(required=False, allow_null=True) - termination_a = CircuitCircuitTerminationSerializer(read_only=True) - termination_z = CircuitCircuitTerminationSerializer(read_only=True) + termination_a = CircuitCircuitTerminationSerializer(read_only=True, allow_null=True) + termination_z = CircuitCircuitTerminationSerializer(read_only=True, allow_null=True) class Meta: model = Circuit diff --git a/netbox/core/api/schema.py b/netbox/core/api/schema.py new file mode 100644 index 0000000000..0b44a3d520 --- /dev/null +++ b/netbox/core/api/schema.py @@ -0,0 +1,224 @@ +import re +import typing + +from drf_spectacular.extensions import ( + OpenApiSerializerFieldExtension, + OpenApiViewExtension, +) +from drf_spectacular.openapi import AutoSchema +from drf_spectacular.plumbing import ( + ComponentRegistry, + ResolvedComponent, + build_basic_type, + build_media_type_object, + build_object_type, + is_serializer, +) +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import extend_schema +from rest_framework.relations import ManyRelatedField + +from netbox.api.fields import ChoiceField, SerializedPKRelatedField +from netbox.api.serializers import WritableNestedSerializer + +# see netbox.api.routers.NetBoxRouter +BULK_ACTIONS = ("bulk_destroy", "bulk_partial_update", "bulk_update") +WRITABLE_ACTIONS = ("PATCH", "POST", "PUT") + + +class FixTimeZoneSerializerField(OpenApiSerializerFieldExtension): + target_class = 'timezone_field.rest_framework.TimeZoneSerializerField' + + def map_serializer_field(self, auto_schema, direction): + return build_basic_type(OpenApiTypes.STR) + + +class ChoiceFieldFix(OpenApiSerializerFieldExtension): + target_class = 'netbox.api.fields.ChoiceField' + + def map_serializer_field(self, auto_schema, direction): + if direction == 'request': + return build_basic_type(OpenApiTypes.STR) + + elif direction == "response": + return build_object_type( + properties={ + "value": build_basic_type(OpenApiTypes.STR), + "label": build_basic_type(OpenApiTypes.STR), + } + ) + + +class NetBoxAutoSchema(AutoSchema): + """ + Overrides to drf_spectacular.openapi.AutoSchema to fix following issues: + 1. bulk serializers cause operation_id conflicts with non-bulk ones + 2. bulk operations should specify a list + 3. bulk operations don't have filter params + 4. bulk operations don't have pagination + 5. bulk delete should specify input + """ + + writable_serializers = {} + + @property + def is_bulk_action(self): + if hasattr(self.view, "action") and self.view.action in BULK_ACTIONS: + return True + else: + return False + + def get_operation_id(self): + """ + bulk serializers cause operation_id conflicts with non-bulk ones + bulk operations cause id conflicts in spectacular resulting in numerous: + Warning: operationId "xxx" has collisions [xxx]. "resolving with numeral suffixes" + code is modified from drf_spectacular.openapi.AutoSchema.get_operation_id + """ + if self.is_bulk_action: + tokenized_path = self._tokenize_path() + # replace dashes as they can be problematic later in code generation + tokenized_path = [t.replace('-', '_') for t in tokenized_path] + + if self.method == 'GET' and self._is_list_view(): + # this shouldn't happen, but keeping it here to follow base code + action = 'list' + else: + # action = self.method_mapping[self.method.lower()] + # use bulk name so partial_update -> bulk_partial_update + action = self.view.action.lower() + + if not tokenized_path: + tokenized_path.append('root') + + if re.search(r'', self.path_regex): + tokenized_path.append('formatted') + + return '_'.join(tokenized_path + [action]) + + # if not bulk - just return normal id + return super().get_operation_id() + + def get_request_serializer(self) -> typing.Any: + # bulk operations should specify a list + serializer = super().get_request_serializer() + + if self.is_bulk_action: + return type(serializer)(many=True) + + # handle mapping for Writable serializers - adapted from dansheps original code + # for drf-yasg + if serializer is not None and self.method in WRITABLE_ACTIONS: + writable_class = self.get_writable_class(serializer) + if writable_class is not None: + if hasattr(serializer, "child"): + child_serializer = self.get_writable_class(serializer.child) + serializer = writable_class(context=serializer.context, child=child_serializer) + else: + serializer = writable_class(context=serializer.context) + + return serializer + + def get_response_serializers(self) -> typing.Any: + # bulk operations should specify a list + response_serializers = super().get_response_serializers() + + if self.is_bulk_action: + return type(response_serializers)(many=True) + + return response_serializers + + def get_serializer_ref_name(self, serializer): + # from drf-yasg.utils + """Get serializer's ref_name (or None for ModelSerializer if it is named 'NestedSerializer') + :param serializer: Serializer instance + :return: Serializer's ``ref_name`` or ``None`` for inline serializer + :rtype: str or None + """ + serializer_meta = getattr(serializer, 'Meta', None) + serializer_name = type(serializer).__name__ + if hasattr(serializer_meta, 'ref_name'): + ref_name = serializer_meta.ref_name + elif serializer_name == 'NestedSerializer' and isinstance(serializer, serializers.ModelSerializer): + ref_name = None + else: + ref_name = serializer_name + if ref_name.endswith('Serializer'): + ref_name = ref_name[: -len('Serializer')] + return ref_name + + def get_writable_class(self, serializer): + properties = {} + fields = {} if hasattr(serializer, 'child') else serializer.fields + + for child_name, child in fields.items(): + if isinstance(child, (ChoiceField, WritableNestedSerializer)): + properties[child_name] = None + elif isinstance(child, ManyRelatedField) and isinstance(child.child_relation, SerializedPKRelatedField): + properties[child_name] = None + + if not properties: + return None + + if type(serializer) not in self.writable_serializers: + writable_name = 'Writable' + type(serializer).__name__ + meta_class = getattr(type(serializer), 'Meta', None) + if meta_class: + ref_name = 'Writable' + self.get_serializer_ref_name(serializer) + writable_meta = type('Meta', (meta_class,), {'ref_name': ref_name}) + properties['Meta'] = writable_meta + + self.writable_serializers[type(serializer)] = type(writable_name, (type(serializer),), properties) + + writable_class = self.writable_serializers[type(serializer)] + return writable_class + + def get_filter_backends(self): + # bulk operations don't have filter params + if self.is_bulk_action: + return [] + return super().get_filter_backends() + + def _get_paginator(self): + # bulk operations don't have pagination + if self.is_bulk_action: + return None + return super()._get_paginator() + + def _get_request_body(self, direction='request'): + # bulk delete should specify input + if (not self.is_bulk_action) or (self.method != 'DELETE'): + return super()._get_request_body(direction) + + # rest from drf_spectacular.openapi.AutoSchema._get_request_body + # but remove the unsafe method check + + request_serializer = self.get_request_serializer() + + if isinstance(request_serializer, dict): + content = [] + request_body_required = True + for media_type, serializer in request_serializer.items(): + schema, partial_request_body_required = self._get_request_for_media_type(serializer, direction) + examples = self._get_examples(serializer, direction, media_type) + if schema is None: + continue + content.append((media_type, schema, examples)) + request_body_required &= partial_request_body_required + else: + schema, request_body_required = self._get_request_for_media_type(request_serializer, direction) + if schema is None: + return None + content = [ + (media_type, schema, self._get_examples(request_serializer, direction, media_type)) + for media_type in self.map_parsers() + ] + + request_body = { + 'content': { + media_type: build_media_type_object(schema, examples) for media_type, schema, examples in content + } + } + if request_body_required: + request_body['required'] = request_body_required + return request_body diff --git a/netbox/core/apps.py b/netbox/core/apps.py index c4886eb41d..ffcf0b4ea8 100644 --- a/netbox/core/apps.py +++ b/netbox/core/apps.py @@ -6,3 +6,4 @@ class CoreConfig(AppConfig): def ready(self): from . import data_backends, search + from core.api import schema # noqa: E402 diff --git a/netbox/dcim/api/nested_serializers.py b/netbox/dcim/api/nested_serializers.py index 29881a548a..3770b2adc6 100644 --- a/netbox/dcim/api/nested_serializers.py +++ b/netbox/dcim/api/nested_serializers.py @@ -1,3 +1,4 @@ +from drf_spectacular.utils import extend_schema_serializer from rest_framework import serializers from dcim import models @@ -53,6 +54,9 @@ # Regions/sites # +@extend_schema_serializer( + exclude_fields=('site_count',), +) class NestedRegionSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:region-detail') site_count = serializers.IntegerField(read_only=True) @@ -63,6 +67,9 @@ class Meta: fields = ['id', 'url', 'display', 'name', 'slug', 'site_count', '_depth'] +@extend_schema_serializer( + exclude_fields=('site_count',), +) class NestedSiteGroupSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:sitegroup-detail') site_count = serializers.IntegerField(read_only=True) @@ -85,6 +92,9 @@ class Meta: # Racks # +@extend_schema_serializer( + exclude_fields=('rack_count',), +) class NestedLocationSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:location-detail') rack_count = serializers.IntegerField(read_only=True) @@ -95,6 +105,9 @@ class Meta: fields = ['id', 'url', 'display', 'name', 'slug', 'rack_count', '_depth'] +@extend_schema_serializer( + exclude_fields=('rack_count',), +) class NestedRackRoleSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail') rack_count = serializers.IntegerField(read_only=True) @@ -104,6 +117,9 @@ class Meta: fields = ['id', 'url', 'display', 'name', 'slug', 'rack_count'] +@extend_schema_serializer( + exclude_fields=('device_count',), +) class NestedRackSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rack-detail') device_count = serializers.IntegerField(read_only=True) @@ -129,6 +145,9 @@ def get_user(self, obj): # Device/module types # +@extend_schema_serializer( + exclude_fields=('devicetype_count',), +) class NestedManufacturerSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail') devicetype_count = serializers.IntegerField(read_only=True) @@ -138,6 +157,9 @@ class Meta: fields = ['id', 'url', 'display', 'name', 'slug', 'devicetype_count'] +@extend_schema_serializer( + exclude_fields=('device_count',), +) class NestedDeviceTypeSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail') manufacturer = NestedManufacturerSerializer(read_only=True) @@ -247,6 +269,9 @@ class Meta: # Devices # +@extend_schema_serializer( + exclude_fields=('device_count', 'virtualmachine_count'), +) class NestedDeviceRoleSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail') device_count = serializers.IntegerField(read_only=True) @@ -257,6 +282,9 @@ class Meta: fields = ['id', 'url', 'display', 'name', 'slug', 'device_count', 'virtualmachine_count'] +@extend_schema_serializer( + exclude_fields=('device_count', 'virtualmachine_count'), +) class NestedPlatformSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail') device_count = serializers.IntegerField(read_only=True) @@ -386,7 +414,7 @@ class Meta: class NestedModuleBaySerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebay-detail') - module = NestedModuleSerializer(read_only=True) + module = NestedModuleSerializer(required=False, read_only=True, allow_null=True) class Meta: model = models.ModuleBay @@ -412,6 +440,9 @@ class Meta: fields = ['id', 'url', 'display', 'device', 'name', '_depth'] +@extend_schema_serializer( + exclude_fields=('inventoryitem_count',), +) class NestedInventoryItemRoleSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitemrole-detail') inventoryitem_count = serializers.IntegerField(read_only=True) @@ -437,6 +468,9 @@ class Meta: # Virtual chassis # +@extend_schema_serializer( + exclude_fields=('member_count',), +) class NestedVirtualChassisSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail') master = NestedDeviceSerializer() @@ -451,6 +485,9 @@ class Meta: # Power panels/feeds # +@extend_schema_serializer( + exclude_fields=('powerfeed_count',), +) class NestedPowerPanelSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerpanel-detail') powerfeed_count = serializers.IntegerField(read_only=True) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index d3a0f99ae2..29eaf2016b 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -2,7 +2,8 @@ from django.contrib.contenttypes.models import ContentType from django.utils.translation import gettext as _ -from drf_yasg.utils import swagger_serializer_method +from drf_spectacular.utils import extend_schema_field +from drf_spectacular.types import OpenApiTypes from rest_framework import serializers from timezone_field.rest_framework import TimeZoneSerializerField @@ -33,12 +34,13 @@ class CabledObjectSerializer(serializers.ModelSerializer): - cable = NestedCableSerializer(read_only=True) + cable = NestedCableSerializer(read_only=True, allow_null=True) cable_end = serializers.CharField(read_only=True) link_peers_type = serializers.SerializerMethodField(read_only=True) link_peers = serializers.SerializerMethodField(read_only=True) _occupied = serializers.SerializerMethodField(read_only=True) + @extend_schema_field(OpenApiTypes.STR) def get_link_peers_type(self, obj): """ Return the type of the peer link terminations, or None. @@ -51,7 +53,7 @@ def get_link_peers_type(self, obj): return None - @swagger_serializer_method(serializer_or_field=serializers.ListField) + @extend_schema_field(serializers.ListField) def get_link_peers(self, obj): """ Return the appropriate serializer for the link termination model. @@ -64,7 +66,7 @@ def get_link_peers(self, obj): context = {'request': self.context['request']} return serializer(obj.link_peers, context=context, many=True).data - @swagger_serializer_method(serializer_or_field=serializers.BooleanField) + @extend_schema_field(serializers.BooleanField) def get__occupied(self, obj): return obj._occupied @@ -77,11 +79,12 @@ class ConnectedEndpointsSerializer(serializers.ModelSerializer): connected_endpoints = serializers.SerializerMethodField(read_only=True) connected_endpoints_reachable = serializers.SerializerMethodField(read_only=True) + @extend_schema_field(OpenApiTypes.STR) def get_connected_endpoints_type(self, obj): if endpoints := obj.connected_endpoints: return f'{endpoints[0]._meta.app_label}.{endpoints[0]._meta.model_name}' - @swagger_serializer_method(serializer_or_field=serializers.ListField) + @extend_schema_field(serializers.ListField) def get_connected_endpoints(self, obj): """ Return the appropriate serializer for the type of connected object. @@ -91,7 +94,7 @@ def get_connected_endpoints(self, obj): context = {'request': self.context['request']} return serializer(endpoints, many=True, context=context).data - @swagger_serializer_method(serializer_or_field=serializers.BooleanField) + @extend_schema_field(serializers.BooleanField) def get_connected_endpoints_reachable(self, obj): return obj._path and obj._path.is_complete and obj._path.is_active @@ -198,12 +201,12 @@ class RackSerializer(NetBoxModelSerializer): tenant = NestedTenantSerializer(required=False, allow_null=True) status = ChoiceField(choices=RackStatusChoices, required=False) role = NestedRackRoleSerializer(required=False, allow_null=True) - type = ChoiceField(choices=RackTypeChoices, allow_blank=True, required=False) + type = ChoiceField(choices=RackTypeChoices, allow_blank=True, required=False, allow_null=True) facility_id = serializers.CharField(max_length=50, allow_blank=True, allow_null=True, label=_('Facility ID'), default=None) width = ChoiceField(choices=RackWidthChoices, required=False) - outer_unit = ChoiceField(choices=RackDimensionUnitChoices, allow_blank=True, required=False) - weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False) + outer_unit = ChoiceField(choices=RackDimensionUnitChoices, allow_blank=True, required=False, allow_null=True) + weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False, allow_null=True) device_count = serializers.IntegerField(read_only=True) powerfeed_count = serializers.IntegerField(read_only=True) @@ -232,6 +235,7 @@ class RackUnitSerializer(serializers.Serializer): occupied = serializers.BooleanField(read_only=True) display = serializers.SerializerMethodField(read_only=True) + @extend_schema_field(OpenApiTypes.STR) def get_display(self, obj): return obj['name'] @@ -318,9 +322,9 @@ class DeviceTypeSerializer(NetBoxModelSerializer): min_value=0, default=1.0 ) - subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False) - airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False) - weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False) + subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False, allow_null=True) + airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False, allow_null=True) + weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False, allow_null=True) device_count = serializers.IntegerField(read_only=True) class Meta: @@ -335,7 +339,7 @@ class Meta: class ModuleTypeSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:moduletype-detail') manufacturer = NestedManufacturerSerializer() - weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False) + weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False, allow_null=True) class Meta: model = ModuleType @@ -416,7 +420,8 @@ class PowerPortTemplateSerializer(ValidatedModelSerializer): type = ChoiceField( choices=PowerPortTypeChoices, allow_blank=True, - required=False + required=False, + allow_null=True ) class Meta: @@ -442,7 +447,8 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer): type = ChoiceField( choices=PowerOutletTypeChoices, allow_blank=True, - required=False + required=False, + allow_null=True ) power_port = NestedPowerPortTemplateSerializer( required=False, @@ -451,7 +457,8 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer): feed_leg = ChoiceField( choices=PowerOutletFeedLegChoices, allow_blank=True, - required=False + required=False, + allow_null=True ) class Meta: @@ -482,12 +489,14 @@ class InterfaceTemplateSerializer(ValidatedModelSerializer): poe_mode = ChoiceField( choices=InterfacePoEModeChoices, required=False, - allow_blank=True + allow_blank=True, + allow_null=True ) poe_type = ChoiceField( choices=InterfacePoETypeChoices, required=False, - allow_blank=True + allow_blank=True, + allow_null=True ) class Meta: @@ -589,7 +598,7 @@ class Meta: 'description', 'component_type', 'component_id', 'component', 'created', 'last_updated', '_depth', ] - @swagger_serializer_method(serializer_or_field=serializers.JSONField) + @extend_schema_field(serializers.JSONField(allow_null=True)) def get_component(self, obj): if obj.component is None: return None @@ -640,7 +649,7 @@ class DeviceSerializer(NetBoxModelSerializer): site = NestedSiteSerializer() location = NestedLocationSerializer(required=False, allow_null=True, default=None) rack = NestedRackSerializer(required=False, allow_null=True, default=None) - face = ChoiceField(choices=DeviceFaceChoices, allow_blank=True, default='') + face = ChoiceField(choices=DeviceFaceChoices, allow_blank=True, default=lambda: '') position = serializers.DecimalField( max_digits=4, decimal_places=1, @@ -669,7 +678,7 @@ class Meta: 'comments', 'config_template', 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated', ] - @swagger_serializer_method(serializer_or_field=NestedDeviceSerializer) + @extend_schema_field(NestedDeviceSerializer) def get_parent_device(self, obj): try: device_bay = obj.parent_bay @@ -682,7 +691,7 @@ def get_parent_device(self, obj): class DeviceWithConfigContextSerializer(DeviceSerializer): - config_context = serializers.SerializerMethodField() + config_context = serializers.SerializerMethodField(read_only=True) class Meta(DeviceSerializer.Meta): fields = [ @@ -692,7 +701,7 @@ class Meta(DeviceSerializer.Meta): 'comments', 'local_context_data', 'tags', 'custom_fields', 'config_context', 'created', 'last_updated', ] - @swagger_serializer_method(serializer_or_field=serializers.JSONField) + @extend_schema_field(serializers.JSONField(allow_null=True)) def get_config_context(self, obj): return obj.get_config_context() @@ -701,7 +710,7 @@ class VirtualDeviceContextSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail') device = NestedDeviceSerializer() tenant = NestedTenantSerializer(required=False, allow_null=True, default=None) - primary_ip = NestedIPAddressSerializer(read_only=True) + primary_ip = NestedIPAddressSerializer(read_only=True, allow_null=True) primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True) primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True) @@ -806,7 +815,8 @@ class PowerOutletSerializer(NetBoxModelSerializer, CabledObjectSerializer, Conne type = ChoiceField( choices=PowerOutletTypeChoices, allow_blank=True, - required=False + required=False, + allow_null=True ) power_port = NestedPowerPortSerializer( required=False, @@ -815,7 +825,8 @@ class PowerOutletSerializer(NetBoxModelSerializer, CabledObjectSerializer, Conne feed_leg = ChoiceField( choices=PowerOutletFeedLegChoices, allow_blank=True, - required=False + required=False, + allow_null=True ) class Meta: @@ -838,7 +849,8 @@ class PowerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect type = ChoiceField( choices=PowerPortTypeChoices, allow_blank=True, - required=False + required=False, + allow_null=True ) class Meta: @@ -868,12 +880,12 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect parent = NestedInterfaceSerializer(required=False, allow_null=True) bridge = NestedInterfaceSerializer(required=False, allow_null=True) lag = NestedInterfaceSerializer(required=False, allow_null=True) - mode = ChoiceField(choices=InterfaceModeChoices, required=False, allow_blank=True) - duplex = ChoiceField(choices=InterfaceDuplexChoices, required=False, allow_blank=True) - rf_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_blank=True) - rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False, allow_blank=True) - poe_mode = ChoiceField(choices=InterfacePoEModeChoices, required=False, allow_blank=True) - poe_type = ChoiceField(choices=InterfacePoETypeChoices, required=False, allow_blank=True) + mode = ChoiceField(choices=InterfaceModeChoices, required=False, allow_blank=True, allow_null=True) + duplex = ChoiceField(choices=InterfaceDuplexChoices, required=False, allow_blank=True, allow_null=True) + rf_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_blank=True, allow_null=True) + rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False, allow_blank=True, allow_null=True) + poe_mode = ChoiceField(choices=InterfacePoEModeChoices, required=False, allow_blank=True, allow_null=True) + poe_type = ChoiceField(choices=InterfacePoETypeChoices, required=False, allow_blank=True, allow_null=True) untagged_vlan = NestedVLANSerializer(required=False, allow_null=True) tagged_vlans = SerializedPKRelatedField( queryset=VLAN.objects.all(), @@ -882,8 +894,8 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect many=True ) vrf = NestedVRFSerializer(required=False, allow_null=True) - l2vpn_termination = NestedL2VPNTerminationSerializer(read_only=True) - wireless_link = NestedWirelessLinkSerializer(read_only=True) + l2vpn_termination = NestedL2VPNTerminationSerializer(read_only=True, allow_null=True) + wireless_link = NestedWirelessLinkSerializer(read_only=True, allow_null=True) wireless_lans = SerializedPKRelatedField( queryset=WirelessLAN.objects.all(), serializer=NestedWirelessLANSerializer, @@ -892,6 +904,8 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect ) count_ipaddresses = serializers.IntegerField(read_only=True) count_fhrp_groups = serializers.IntegerField(read_only=True) + mac_address = serializers.CharField(required=False, default=None) + wwn = serializers.CharField(required=False, default=None) class Meta: model = Interface @@ -1015,7 +1029,7 @@ class Meta: 'custom_fields', 'created', 'last_updated', '_depth', ] - @swagger_serializer_method(serializer_or_field=serializers.JSONField) + @extend_schema_field(serializers.JSONField(allow_null=True)) def get_component(self, obj): if obj.component is None: return None @@ -1050,7 +1064,7 @@ class CableSerializer(NetBoxModelSerializer): b_terminations = GenericObjectSerializer(many=True, required=False) status = ChoiceField(choices=LinkStatusChoices, required=False) tenant = NestedTenantSerializer(required=False, allow_null=True) - length_unit = ChoiceField(choices=CableLengthUnitChoices, allow_blank=True, required=False) + length_unit = ChoiceField(choices=CableLengthUnitChoices, allow_blank=True, required=False, allow_null=True) class Meta: model = Cable @@ -1086,7 +1100,7 @@ class Meta: 'id', 'url', 'display', 'cable', 'cable_end', 'termination_type', 'termination_id', 'termination' ] - @swagger_serializer_method(serializer_or_field=serializers.JSONField) + @extend_schema_field(serializers.JSONField(allow_null=True)) def get_termination(self, obj): serializer = get_serializer_for_model(obj.termination, prefix=NESTED_SERIALIZER_PREFIX) context = {'request': self.context['request']} @@ -1100,7 +1114,7 @@ class Meta: model = CablePath fields = ['id', 'path', 'is_active', 'is_complete', 'is_split'] - @swagger_serializer_method(serializer_or_field=serializers.ListField) + @extend_schema_field(serializers.ListField) def get_path(self, obj): ret = [] for nodes in obj.path_objects: @@ -1159,19 +1173,19 @@ class PowerFeedSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect ) type = ChoiceField( choices=PowerFeedTypeChoices, - default=PowerFeedTypeChoices.TYPE_PRIMARY + default=lambda: PowerFeedTypeChoices.TYPE_PRIMARY, ) status = ChoiceField( choices=PowerFeedStatusChoices, - default=PowerFeedStatusChoices.STATUS_ACTIVE + default=lambda: PowerFeedStatusChoices.STATUS_ACTIVE, ) supply = ChoiceField( choices=PowerFeedSupplyChoices, - default=PowerFeedSupplyChoices.SUPPLY_AC + default=lambda: PowerFeedSupplyChoices.SUPPLY_AC, ) phase = ChoiceField( choices=PowerFeedPhaseChoices, - default=PowerFeedPhaseChoices.PHASE_SINGLE + default=lambda: PowerFeedPhaseChoices.PHASE_SINGLE, ) class Meta: diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 37151b96cf..769584d21b 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -1,8 +1,7 @@ from django.http import Http404, HttpResponse from django.shortcuts import get_object_or_404 -from drf_yasg import openapi -from drf_yasg.openapi import Parameter -from drf_yasg.utils import swagger_auto_schema +from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter +from drf_spectacular.types import OpenApiTypes from rest_framework.decorators import action from rest_framework.renderers import JSONRenderer from rest_framework.response import Response @@ -194,10 +193,6 @@ class RackViewSet(NetBoxModelViewSet): serializer_class = serializers.RackSerializer filterset_class = filtersets.RackFilterSet - @swagger_auto_schema( - responses={200: serializers.RackUnitSerializer(many=True)}, - query_serializer=serializers.RackElevationDetailFilterSerializer - ) @action(detail=True) def elevation(self, request, pk=None): """ @@ -622,28 +617,26 @@ class ConnectedDeviceViewSet(ViewSet): * `peer_interface`: The name of the peer interface """ permission_classes = [IsAuthenticatedOrLoginNotRequired] - _device_param = Parameter( + _device_param = OpenApiParameter( name='peer_device', - in_='query', + location='query', description='The name of the peer device', required=True, - type=openapi.TYPE_STRING + type=OpenApiTypes.STR ) - _interface_param = Parameter( + _interface_param = OpenApiParameter( name='peer_interface', - in_='query', + location='query', description='The name of the peer interface', required=True, - type=openapi.TYPE_STRING + type=OpenApiTypes.STR ) + serializer_class = serializers.DeviceSerializer def get_view_name(self): return "Connected Device Locator" - @swagger_auto_schema( - manual_parameters=[_device_param, _interface_param], - responses={'200': serializers.DeviceSerializer} - ) + @extend_schema(responses={200: OpenApiTypes.OBJECT}) def list(self, request): peer_device_name = request.query_params.get(self._device_param.name) diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 301af8d182..3445b7e758 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -1674,7 +1674,7 @@ def setUpTestData(cls): class ModuleBayTest(APIViewTestCases.APIViewTestCase): model = ModuleBay - brief_fields = ['display', 'id', 'name', 'url'] + brief_fields = ['display', 'id', 'module', 'name', 'url'] bulk_update_data = { 'description': 'New description', } diff --git a/netbox/extras/api/customfields.py b/netbox/extras/api/customfields.py index d16fc0dafa..ce3b388f40 100644 --- a/netbox/extras/api/customfields.py +++ b/netbox/extras/api/customfields.py @@ -1,4 +1,6 @@ from django.contrib.contenttypes.models import ContentType +from drf_spectacular.utils import extend_schema_field +from drf_spectacular.types import OpenApiTypes from rest_framework.fields import Field from rest_framework.serializers import ValidationError @@ -36,6 +38,7 @@ def __call__(self, serializer_field): return value +@extend_schema_field(OpenApiTypes.OBJECT) class CustomFieldsDataField(Field): def _get_custom_fields(self): diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index cc78698f8d..4054f3bd4e 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -1,7 +1,6 @@ from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist -from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers from core.api.serializers import JobSerializer @@ -11,6 +10,8 @@ NestedRegionSerializer, NestedSiteSerializer, NestedSiteGroupSerializer, ) from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup +from drf_spectacular.utils import extend_schema_field +from drf_spectacular.types import OpenApiTypes from extras.choices import * from extras.models import * from extras.utils import FeatureQuery @@ -103,6 +104,7 @@ class Meta: 'last_updated', ] + @extend_schema_field(OpenApiTypes.STR) def get_data_type(self, obj): types = CustomFieldTypeChoices if obj.type == types.TYPE_INTEGER: @@ -230,7 +232,7 @@ def validate(self, data): return data - @swagger_serializer_method(serializer_or_field=serializers.JSONField) + @extend_schema_field(serializers.JSONField(allow_null=True)) def get_parent(self, obj): serializer = get_serializer_for_model(obj.parent, prefix=NESTED_SERIALIZER_PREFIX) return serializer(obj.parent, context={'request': self.context['request']}).data @@ -280,7 +282,7 @@ def validate(self, data): return data - @swagger_serializer_method(serializer_or_field=serializers.JSONField) + @extend_schema_field(serializers.JSONField(allow_null=True)) def get_assigned_object(self, instance): serializer = get_serializer_for_model(instance.assigned_object_type.model_class(), prefix=NESTED_SERIALIZER_PREFIX) context = {'request': self.context['request']} @@ -453,7 +455,7 @@ class ScriptSerializer(serializers.Serializer): vars = serializers.SerializerMethodField(read_only=True) result = NestedJobSerializer() - @swagger_serializer_method(serializer_or_field=serializers.JSONField) + @extend_schema_field(serializers.JSONField(allow_null=True)) def get_vars(self, instance): return { k: v.__class__.__name__ for k, v in instance._get_vars().items() @@ -514,7 +516,7 @@ class Meta: 'changed_object_id', 'changed_object', 'prechange_data', 'postchange_data', ] - @swagger_serializer_method(serializer_or_field=serializers.JSONField) + @extend_schema_field(serializers.JSONField(allow_null=True)) def get_changed_object(self, obj): """ Serialize a nested representation of the changed object. diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 65f74e2e29..e132c0327c 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -168,7 +168,7 @@ def render(self, request, pk): class ReportViewSet(ViewSet): permission_classes = [IsAuthenticatedOrLoginNotRequired] _ignore_model_permissions = True - exclude_from_schema = True + schema = None lookup_value_regex = '[^/]+' # Allow dots def _get_report(self, pk): @@ -270,7 +270,7 @@ def run(self, request, pk): class ScriptViewSet(ViewSet): permission_classes = [IsAuthenticatedOrLoginNotRequired] _ignore_model_permissions = True - exclude_from_schema = True + schema = None lookup_value_regex = '[^/]+' # Allow dots def _get_script(self, pk): diff --git a/netbox/extras/plugins/views.py b/netbox/extras/plugins/views.py index 65b33f0c4d..5971f78ef9 100644 --- a/netbox/extras/plugins/views.py +++ b/netbox/extras/plugins/views.py @@ -5,6 +5,7 @@ from django.shortcuts import render from django.urls.exceptions import NoReverseMatch from django.views.generic import View +from drf_spectacular.utils import extend_schema from rest_framework import permissions from rest_framework.response import Response from rest_framework.reverse import reverse @@ -22,14 +23,14 @@ def get(self, request): }) +@extend_schema(exclude=True) class InstalledPluginsAPIView(APIView): """ API view for listing all installed plugins """ permission_classes = [permissions.IsAdminUser] _ignore_model_permissions = True - exclude_from_schema = True - swagger_schema = None + schema = None def get_view_name(self): return "Installed Plugins" @@ -49,10 +50,10 @@ def get(self, request, format=None): return Response([self._get_plugin_data(apps.get_app_config(plugin)) for plugin in settings.PLUGINS]) +@extend_schema(exclude=True) class PluginsAPIRootView(APIView): _ignore_model_permissions = True - exclude_from_schema = True - swagger_schema = None + schema = None def get_view_name(self): return "Plugins" diff --git a/netbox/ipam/api/nested_serializers.py b/netbox/ipam/api/nested_serializers.py index ca88432015..57bb58b5ed 100644 --- a/netbox/ipam/api/nested_serializers.py +++ b/netbox/ipam/api/nested_serializers.py @@ -1,3 +1,4 @@ +from drf_spectacular.utils import extend_schema_serializer from rest_framework import serializers from ipam import models @@ -54,6 +55,9 @@ class Meta: # VRFs # +@extend_schema_serializer( + exclude_fields=('prefix_count',), +) class NestedVRFSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vrf-detail') prefix_count = serializers.IntegerField(read_only=True) @@ -79,6 +83,9 @@ class Meta: # RIRs/aggregates # +@extend_schema_serializer( + exclude_fields=('aggregate_count',), +) class NestedRIRSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:rir-detail') aggregate_count = serializers.IntegerField(read_only=True) @@ -121,6 +128,9 @@ class Meta: # VLANs # +@extend_schema_serializer( + exclude_fields=('prefix_count', 'vlan_count'), +) class NestedRoleSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:role-detail') prefix_count = serializers.IntegerField(read_only=True) @@ -131,6 +141,9 @@ class Meta: fields = ['id', 'url', 'display', 'name', 'slug', 'prefix_count', 'vlan_count'] +@extend_schema_serializer( + exclude_fields=('vlan_count',), +) class NestedVLANGroupSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlangroup-detail') vlan_count = serializers.IntegerField(read_only=True) diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index dc0817b8a9..10c27b2522 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -1,5 +1,5 @@ from django.contrib.contenttypes.models import ContentType -from drf_yasg.utils import swagger_serializer_method +from drf_spectacular.utils import extend_schema_field from rest_framework import serializers from dcim.api.nested_serializers import NestedDeviceSerializer, NestedSiteSerializer @@ -136,6 +136,7 @@ class AggregateSerializer(NetBoxModelSerializer): family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True) rir = NestedRIRSerializer() tenant = NestedTenantSerializer(required=False, allow_null=True) + prefix = serializers.CharField() class Meta: model = Aggregate @@ -177,7 +178,7 @@ class Meta: 'last_updated', ] - @swagger_serializer_method(serializer_or_field=serializers.JSONField) + @extend_schema_field(serializers.JSONField(allow_null=True)) def get_interface(self, obj): if obj.interface is None: return None @@ -225,7 +226,7 @@ class Meta: ] validators = [] - @swagger_serializer_method(serializer_or_field=serializers.JSONField) + @extend_schema_field(serializers.JSONField(allow_null=True)) def get_scope(self, obj): if obj.scope_id is None: return None @@ -242,7 +243,7 @@ class VLANSerializer(NetBoxModelSerializer): tenant = NestedTenantSerializer(required=False, allow_null=True) status = ChoiceField(choices=VLANStatusChoices, required=False) role = NestedRoleSerializer(required=False, allow_null=True) - l2vpn_termination = NestedL2VPNTerminationSerializer(read_only=True) + l2vpn_termination = NestedL2VPNTerminationSerializer(read_only=True, allow_null=True) prefix_count = serializers.IntegerField(read_only=True) class Meta: @@ -302,6 +303,7 @@ class PrefixSerializer(NetBoxModelSerializer): role = NestedRoleSerializer(required=False, allow_null=True) children = serializers.IntegerField(read_only=True) _depth = serializers.IntegerField(read_only=True) + prefix = serializers.CharField() class Meta: model = Prefix @@ -371,13 +373,13 @@ class IPRangeSerializer(NetBoxModelSerializer): tenant = NestedTenantSerializer(required=False, allow_null=True) status = ChoiceField(choices=IPRangeStatusChoices, required=False) role = NestedRoleSerializer(required=False, allow_null=True) - children = serializers.IntegerField(read_only=True) class Meta: model = IPRange fields = [ 'id', 'url', 'display', 'family', 'start_address', 'end_address', 'size', 'vrf', 'tenant', 'status', 'role', - 'mark_utilized', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'children', + 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', + 'mark_utilized', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ] read_only_fields = ['family'] @@ -392,7 +394,7 @@ class IPAddressSerializer(NetBoxModelSerializer): vrf = NestedVRFSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True) status = ChoiceField(choices=IPAddressStatusChoices, required=False) - role = ChoiceField(choices=IPAddressRoleChoices, allow_blank=True, required=False) + role = ChoiceField(choices=IPAddressRoleChoices, allow_blank=True, required=False, allow_null=True) assigned_object_type = ContentTypeField( queryset=ContentType.objects.filter(IPADDRESS_ASSIGNMENT_MODELS), required=False, @@ -410,7 +412,7 @@ class Meta: 'tags', 'custom_fields', 'created', 'last_updated', ] - @swagger_serializer_method(serializer_or_field=serializers.JSONField) + @extend_schema_field(serializers.JSONField(allow_null=True)) def get_assigned_object(self, obj): if obj.assigned_object is None: return None @@ -519,7 +521,7 @@ class Meta: 'assigned_object', 'tags', 'custom_fields', 'created', 'last_updated' ] - @swagger_serializer_method(serializer_or_field=serializers.JSONField) + @extend_schema_field(serializers.JSONField(allow_null=True)) def get_assigned_object(self, instance): serializer = get_serializer_for_model(instance.assigned_object, prefix=NESTED_SERIALIZER_PREFIX) context = {'request': self.context['request']} diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 4eafd4d3be..5263b049ab 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -2,7 +2,7 @@ from django.db import transaction from django.shortcuts import get_object_or_404 from django_pglocks import advisory_lock -from drf_yasg.utils import swagger_auto_schema +from drf_spectacular.utils import extend_schema, extend_schema_view from rest_framework import status from rest_framework.response import Response from rest_framework.routers import APIRootView @@ -210,7 +210,7 @@ def get_results_limit(request): class AvailableASNsView(ObjectValidationMixin, APIView): queryset = ASN.objects.all() - @swagger_auto_schema(responses={200: serializers.AvailableASNSerializer(many=True)}) + @extend_schema(methods=["get"], responses={200: serializers.AvailableASNSerializer(many=True)}) def get(self, request, pk): asnrange = get_object_or_404(ASNRange.objects.restrict(request.user), pk=pk) limit = get_results_limit(request) @@ -224,10 +224,7 @@ def get(self, request, pk): return Response(serializer.data) - @swagger_auto_schema( - request_body=serializers.AvailableASNSerializer, - responses={201: serializers.ASNSerializer(many=True)} - ) + @extend_schema(methods=["post"], responses={201: serializers.ASNSerializer(many=True)}) @advisory_lock(ADVISORY_LOCK_KEYS['available-asns']) def post(self, request, pk): self.queryset = self.queryset.restrict(request.user, 'add') @@ -274,11 +271,17 @@ def post(self, request, pk): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + def get_serializer_class(self): + if self.request.method == "GET": + return serializers.AvailableASNSerializer + + return serializers.ASNSerializer + class AvailablePrefixesView(ObjectValidationMixin, APIView): queryset = Prefix.objects.all() - @swagger_auto_schema(responses={200: serializers.AvailablePrefixSerializer(many=True)}) + @extend_schema(methods=["get"], responses={200: serializers.AvailablePrefixSerializer(many=True)}) def get(self, request, pk): prefix = get_object_or_404(Prefix.objects.restrict(request.user), pk=pk) available_prefixes = prefix.get_available_prefixes() @@ -290,10 +293,7 @@ def get(self, request, pk): return Response(serializer.data) - @swagger_auto_schema( - request_body=serializers.PrefixLengthSerializer, - responses={201: serializers.PrefixSerializer(many=True)} - ) + @extend_schema(methods=["post"], responses={201: serializers.PrefixSerializer(many=True)}) @advisory_lock(ADVISORY_LOCK_KEYS['available-prefixes']) def post(self, request, pk): self.queryset = self.queryset.restrict(request.user, 'add') @@ -356,6 +356,12 @@ def post(self, request, pk): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + def get_serializer_class(self): + if self.request.method == "GET": + return serializers.AvailablePrefixSerializer + + return serializers.PrefixLengthSerializer + class AvailableIPAddressesView(ObjectValidationMixin, APIView): queryset = IPAddress.objects.all() @@ -363,7 +369,7 @@ class AvailableIPAddressesView(ObjectValidationMixin, APIView): def get_parent(self, request, pk): raise NotImplemented() - @swagger_auto_schema(responses={200: serializers.AvailableIPSerializer(many=True)}) + @extend_schema(methods=["get"], responses={200: serializers.AvailableIPSerializer(many=True)}) def get(self, request, pk): parent = self.get_parent(request, pk) limit = get_results_limit(request) @@ -382,10 +388,7 @@ def get(self, request, pk): return Response(serializer.data) - @swagger_auto_schema( - request_body=serializers.AvailableIPSerializer, - responses={201: serializers.IPAddressSerializer(many=True)} - ) + @extend_schema(methods=["post"], responses={201: serializers.IPAddressSerializer(many=True)}) @advisory_lock(ADVISORY_LOCK_KEYS['available-ips']) def post(self, request, pk): self.queryset = self.queryset.restrict(request.user, 'add') @@ -430,6 +433,12 @@ def post(self, request, pk): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + def get_serializer_class(self): + if self.request.method == "GET": + return serializers.AvailableIPSerializer + + return serializers.IPAddressSerializer + class PrefixAvailableIPAddressesView(AvailableIPAddressesView): @@ -446,7 +455,7 @@ def get_parent(self, request, pk): class AvailableVLANsView(ObjectValidationMixin, APIView): queryset = VLAN.objects.all() - @swagger_auto_schema(responses={200: serializers.AvailableVLANSerializer(many=True)}) + @extend_schema(methods=["get"], responses={200: serializers.AvailableVLANSerializer(many=True)}) def get(self, request, pk): vlangroup = get_object_or_404(VLANGroup.objects.restrict(request.user), pk=pk) limit = get_results_limit(request) @@ -459,10 +468,7 @@ def get(self, request, pk): return Response(serializer.data) - @swagger_auto_schema( - request_body=serializers.CreateAvailableVLANSerializer, - responses={201: serializers.VLANSerializer(many=True)} - ) + @extend_schema(methods=["post"], responses={201: serializers.VLANSerializer(many=True)}) @advisory_lock(ADVISORY_LOCK_KEYS['available-vlans']) def post(self, request, pk): self.queryset = self.queryset.restrict(request.user, 'add') @@ -514,3 +520,9 @@ def post(self, request, pk): return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def get_serializer_class(self): + if self.request.method == "GET": + return serializers.AvailableVLANSerializer + + return serializers.VLANSerializer diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 10b5157154..a128b6acc1 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -16,8 +16,6 @@ from .choices import * from .models import * -from rest_framework import serializers - __all__ = ( 'AggregateFilterSet', 'ASNFilterSet', diff --git a/netbox/netbox/api/fields.py b/netbox/netbox/api/fields.py index 52343c2f64..ed70c28aca 100644 --- a/netbox/netbox/api/fields.py +++ b/netbox/netbox/api/fields.py @@ -1,4 +1,6 @@ from django.core.exceptions import ObjectDoesNotExist +from drf_spectacular.utils import extend_schema_field +from drf_spectacular.types import OpenApiTypes from netaddr import IPNetwork from rest_framework import serializers from rest_framework.exceptions import ValidationError @@ -12,6 +14,7 @@ ) +@extend_schema_field(OpenApiTypes.STR) class ChoiceField(serializers.Field): """ Represent a ChoiceField as {'value': , 'label': }. Accepts a single value on write. @@ -86,6 +89,7 @@ def choices(self): return self._choices +@extend_schema_field(OpenApiTypes.STR) class ContentTypeField(RelatedField): """ Represent a ContentType as '.' diff --git a/netbox/netbox/api/serializers/base.py b/netbox/netbox/api/serializers/base.py index f1aea0e2b2..5ee74bf8c5 100644 --- a/netbox/netbox/api/serializers/base.py +++ b/netbox/netbox/api/serializers/base.py @@ -1,5 +1,7 @@ from django.db.models import ManyToManyField from rest_framework import serializers +from drf_spectacular.utils import extend_schema_field +from drf_spectacular.types import OpenApiTypes __all__ = ( 'BaseModelSerializer', @@ -10,6 +12,7 @@ class BaseModelSerializer(serializers.ModelSerializer): display = serializers.SerializerMethodField(read_only=True) + @extend_schema_field(OpenApiTypes.STR) def get_display(self, obj): return str(obj) diff --git a/netbox/netbox/api/serializers/generic.py b/netbox/netbox/api/serializers/generic.py index f01e6995a4..545ebb9364 100644 --- a/netbox/netbox/api/serializers/generic.py +++ b/netbox/netbox/api/serializers/generic.py @@ -1,5 +1,5 @@ from django.contrib.contenttypes.models import ContentType -from drf_yasg.utils import swagger_serializer_method +from drf_spectacular.utils import extend_schema_field from rest_framework import serializers from netbox.api.fields import ContentTypeField @@ -38,7 +38,7 @@ def to_representation(self, instance): return data - @swagger_serializer_method(serializer_or_field=serializers.JSONField) + @extend_schema_field(serializers.JSONField(allow_null=True)) def get_object(self, obj): serializer = get_serializer_for_model(obj, prefix=NESTED_SERIALIZER_PREFIX) # context = {'request': self.context['request']} diff --git a/netbox/netbox/api/views.py b/netbox/netbox/api/views.py index 023843bcae..5c55697ff6 100644 --- a/netbox/netbox/api/views.py +++ b/netbox/netbox/api/views.py @@ -4,6 +4,8 @@ from django.apps import apps from django.conf import settings from django_rq.queues import get_connection +from drf_spectacular.utils import extend_schema +from drf_spectacular.types import OpenApiTypes from rest_framework.response import Response from rest_framework.reverse import reverse from rest_framework.views import APIView @@ -17,12 +19,12 @@ class APIRootView(APIView): This is the root of NetBox's REST API. API endpoints are arranged by app and model name; e.g. `/api/dcim/sites/`. """ _ignore_model_permissions = True - exclude_from_schema = True - swagger_schema = None + # schema = None def get_view_name(self): return "API Root" + @extend_schema(exclude=True) def get(self, request, format=None): return Response({ @@ -46,6 +48,7 @@ class StatusView(APIView): """ permission_classes = [IsAuthenticatedOrLoginNotRequired] + @extend_schema(responses={200: OpenApiTypes.OBJECT}) def get(self, request): # Gather the version numbers from all installed Django apps installed_apps = {} diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 5d18787012..3b162b261d 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -344,7 +344,7 @@ def _setting(name, default=None): 'virtualization', 'wireless', 'django_rq', # Must come after extras to allow overriding management commands - 'drf_yasg', + 'drf_spectacular', ] # Middleware @@ -561,6 +561,7 @@ def _setting(name, default=None): 'rest_framework.renderers.JSONRenderer', 'netbox.api.renderers.FormlessBrowsableAPIRenderer', ), + 'DEFAULT_SCHEMA_CLASS': 'core.api.schema.NetBoxAutoSchema', 'DEFAULT_VERSION': REST_FRAMEWORK_VERSION, 'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.AcceptHeaderVersioning', 'SCHEMA_COERCE_METHOD_NAMES': { @@ -573,6 +574,17 @@ def _setting(name, default=None): 'VIEW_NAME_FUNCTION': 'utilities.api.get_view_name', } +# +# DRF Spectacular +# + +SPECTACULAR_SETTINGS = { + "TITLE": "NetBox API", + "DESCRIPTION": "API to access NetBox", + "LICENSE": {"name": "Apache v2 License"}, + "VERSION": VERSION, + 'COMPONENT_SPLIT_REQUEST': True, +} # # Graphene @@ -585,49 +597,6 @@ def _setting(name, default=None): } -# -# drf_yasg (OpenAPI/Swagger) -# - -SWAGGER_SETTINGS = { - 'DEFAULT_AUTO_SCHEMA_CLASS': 'utilities.custom_inspectors.NetBoxSwaggerAutoSchema', - 'DEFAULT_FIELD_INSPECTORS': [ - 'utilities.custom_inspectors.CustomFieldsDataFieldInspector', - 'utilities.custom_inspectors.NullableBooleanFieldInspector', - 'utilities.custom_inspectors.ChoiceFieldInspector', - 'utilities.custom_inspectors.SerializedPKRelatedFieldInspector', - 'drf_yasg.inspectors.CamelCaseJSONFilter', - 'drf_yasg.inspectors.ReferencingSerializerInspector', - 'drf_yasg.inspectors.RelatedFieldInspector', - 'drf_yasg.inspectors.ChoiceFieldInspector', - 'drf_yasg.inspectors.FileFieldInspector', - 'drf_yasg.inspectors.DictFieldInspector', - 'drf_yasg.inspectors.JSONFieldInspector', - 'drf_yasg.inspectors.SerializerMethodFieldInspector', - 'drf_yasg.inspectors.SimpleFieldInspector', - 'drf_yasg.inspectors.StringDefaultFieldInspector', - ], - 'DEFAULT_FILTER_INSPECTORS': [ - 'drf_yasg.inspectors.CoreAPICompatInspector', - ], - 'DEFAULT_INFO': 'netbox.urls.openapi_info', - 'DEFAULT_MODEL_DEPTH': 1, - 'DEFAULT_PAGINATOR_INSPECTORS': [ - 'utilities.custom_inspectors.NullablePaginatorInspector', - 'drf_yasg.inspectors.DjangoRestResponsePagination', - 'drf_yasg.inspectors.CoreAPICompatInspector', - ], - 'SECURITY_DEFINITIONS': { - 'Bearer': { - 'type': 'apiKey', - 'name': 'Authorization', - 'in': 'header', - } - }, - 'VALIDATOR_URL': None, -} - - # # Django RQ (Webhooks backend) # diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index 8c859528c7..4162fd382d 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -3,8 +3,7 @@ from django.urls import path, re_path from django.views.decorators.csrf import csrf_exempt from django.views.static import serve -from drf_yasg import openapi -from drf_yasg.views import get_schema_view +from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView from extras.plugins.urls import plugin_admin_patterns, plugin_patterns, plugin_api_patterns from netbox.api.views import APIRootView, StatusView @@ -14,20 +13,6 @@ from users.views import LoginView, LogoutView from .admin import admin_site -openapi_info = openapi.Info( - title="NetBox API", - default_version='v3', - description="API to access NetBox", - terms_of_service="https://github.com/netbox-community/netbox", - license=openapi.License(name="Apache v2 License"), -) - -schema_view = get_schema_view( - openapi_info, - validators=['flex', 'ssv'], - public=True, - permission_classes=() -) _patterns = [ @@ -66,9 +51,10 @@ path('api/virtualization/', include('virtualization.api.urls')), path('api/wireless/', include('wireless.api.urls')), path('api/status/', StatusView.as_view(), name='api-status'), - path('api/docs/', schema_view.with_ui('swagger', cache_timeout=86400), name='api_docs'), - path('api/redoc/', schema_view.with_ui('redoc', cache_timeout=86400), name='api_redocs'), - re_path(r'^api/swagger(?P.json|.yaml)$', schema_view.without_ui(cache_timeout=86400), name='schema_swagger'), + + path('api/schema/', SpectacularAPIView.as_view(), name='schema'), + path('api/schema/swagger-ui/', SpectacularSwaggerView.as_view(url_name='schema'), name='api_docs'), + path('api/schema/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='api_redocs'), # GraphQL path('graphql/', csrf_exempt(GraphQLView.as_view(graphiql=True, schema=schema)), name='graphql'), diff --git a/netbox/tenancy/api/nested_serializers.py b/netbox/tenancy/api/nested_serializers.py index 2f95eca8c6..d2d76d96ca 100644 --- a/netbox/tenancy/api/nested_serializers.py +++ b/netbox/tenancy/api/nested_serializers.py @@ -1,3 +1,4 @@ +from drf_spectacular.utils import extend_schema_serializer from rest_framework import serializers from netbox.api.serializers import WritableNestedSerializer @@ -17,6 +18,9 @@ # Tenants # +@extend_schema_serializer( + exclude_fields=('tenant_count',), +) class NestedTenantGroupSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenantgroup-detail') tenant_count = serializers.IntegerField(read_only=True) @@ -39,6 +43,9 @@ class Meta: # Contacts # +@extend_schema_serializer( + exclude_fields=('contact_count',), +) class NestedContactGroupSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactgroup-detail') contact_count = serializers.IntegerField(read_only=True) diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py index c8ef771178..75d68a4a59 100644 --- a/netbox/tenancy/api/serializers.py +++ b/netbox/tenancy/api/serializers.py @@ -1,5 +1,6 @@ from django.contrib.auth.models import ContentType -from drf_yasg.utils import swagger_serializer_method +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import extend_schema_field from rest_framework import serializers from netbox.api.fields import ChoiceField, ContentTypeField @@ -98,7 +99,7 @@ class ContactAssignmentSerializer(NetBoxModelSerializer): object = serializers.SerializerMethodField(read_only=True) contact = NestedContactSerializer() role = NestedContactRoleSerializer(required=False, allow_null=True) - priority = ChoiceField(choices=ContactPriorityChoices, allow_blank=True, required=False, default='') + priority = ChoiceField(choices=ContactPriorityChoices, allow_blank=True, required=False, default=lambda: '') class Meta: model = ContactAssignment @@ -107,7 +108,7 @@ class Meta: 'last_updated', ] - @swagger_serializer_method(serializer_or_field=serializers.JSONField) + @extend_schema_field(OpenApiTypes.OBJECT) def get_object(self, instance): serializer = get_serializer_for_model(instance.content_type.model_class(), prefix=NESTED_SERIALIZER_PREFIX) context = {'request': self.context['request']} diff --git a/netbox/users/api/nested_serializers.py b/netbox/users/api/nested_serializers.py index 0d8f7ae42f..3510184ae9 100644 --- a/netbox/users/api/nested_serializers.py +++ b/netbox/users/api/nested_serializers.py @@ -1,6 +1,7 @@ from django.contrib.auth.models import Group, User from django.contrib.contenttypes.models import ContentType -from drf_yasg.utils import swagger_serializer_method +from drf_spectacular.utils import extend_schema_field +from drf_spectacular.types import OpenApiTypes from rest_framework import serializers from netbox.api.fields import ContentTypeField @@ -30,6 +31,7 @@ class Meta: model = User fields = ['id', 'url', 'display', 'username'] + @extend_schema_field(OpenApiTypes.STR) def get_display(self, obj): if full_name := obj.get_full_name(): return f"{obj.username} ({full_name})" @@ -57,10 +59,10 @@ class Meta: model = ObjectPermission fields = ['id', 'url', 'display', 'name', 'enabled', 'object_types', 'groups', 'users', 'actions'] - @swagger_serializer_method(serializer_or_field=serializers.ListField) + @extend_schema_field(serializers.ListField) def get_groups(self, obj): return [g.name for g in obj.groups.all()] - @swagger_serializer_method(serializer_or_field=serializers.ListField) + @extend_schema_field(serializers.ListField) def get_users(self, obj): return [u.username for u in obj.users.all()] diff --git a/netbox/users/api/serializers.py b/netbox/users/api/serializers.py index f1f1fc975b..1f3a75b10a 100644 --- a/netbox/users/api/serializers.py +++ b/netbox/users/api/serializers.py @@ -1,6 +1,8 @@ from django.conf import settings from django.contrib.auth.models import Group, User from django.contrib.contenttypes.models import ContentType +from drf_spectacular.utils import extend_schema_field +from drf_spectacular.types import OpenApiTypes from rest_framework import serializers from netbox.api.fields import ContentTypeField, IPNetworkSerializer, SerializedPKRelatedField @@ -47,6 +49,7 @@ def create(self, validated_data): return user + @extend_schema_field(OpenApiTypes.STR) def get_display(self, obj): if full_name := obj.get_full_name(): return f"{obj.username} ({full_name})" diff --git a/netbox/users/api/views.py b/netbox/users/api/views.py index 86a66a01f5..04b3ae336b 100644 --- a/netbox/users/api/views.py +++ b/netbox/users/api/views.py @@ -1,6 +1,8 @@ from django.contrib.auth import authenticate from django.contrib.auth.models import Group, User from django.db.models import Count +from drf_spectacular.utils import extend_schema +from drf_spectacular.types import OpenApiTypes from rest_framework.exceptions import AuthenticationFailed from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response @@ -55,9 +57,6 @@ def get_queryset(self): Limit the non-superusers to their own Tokens. """ queryset = super().get_queryset() - # Workaround for schema generation (drf_yasg) - if getattr(self, 'swagger_fake_view', False): - return queryset.none() if not self.request.user.is_authenticated: return queryset.none() if self.request.user.is_superuser: @@ -71,6 +70,7 @@ class TokenProvisionView(APIView): """ permission_classes = [] + # @extend_schema(methods=["post"], responses={201: serializers.TokenSerializer}) def post(self, request): serializer = serializers.TokenProvisionSerializer(data=request.data) serializer.is_valid() @@ -93,6 +93,9 @@ def post(self, request): return Response(data, status=HTTP_201_CREATED) + def get_serializer_class(self): + return serializers.TokenSerializer + # # ObjectPermissions @@ -117,6 +120,7 @@ class UserConfigViewSet(ViewSet): def get_queryset(self): return UserConfig.objects.filter(user=self.request.user) + @extend_schema(responses={200: OpenApiTypes.OBJECT}) def list(self, request): """ Return the UserConfig for the currently authenticated User. @@ -125,6 +129,7 @@ def list(self, request): return Response(userconfig.data) + @extend_schema(methods=["patch"], responses={201: OpenApiTypes.OBJECT}) def patch(self, request): """ Update the UserConfig for the currently authenticated User. diff --git a/netbox/utilities/custom_inspectors.py b/netbox/utilities/custom_inspectors.py deleted file mode 100644 index 7cf9fe02f8..0000000000 --- a/netbox/utilities/custom_inspectors.py +++ /dev/null @@ -1,142 +0,0 @@ -from drf_yasg import openapi -from drf_yasg.inspectors import FieldInspector, NotHandled, PaginatorInspector, SwaggerAutoSchema -from drf_yasg.utils import get_serializer_ref_name -from rest_framework.fields import ChoiceField -from rest_framework.relations import ManyRelatedField - -from extras.api.customfields import CustomFieldsDataField -from netbox.api.fields import ChoiceField, SerializedPKRelatedField -from netbox.api.serializers import WritableNestedSerializer - - -class NetBoxSwaggerAutoSchema(SwaggerAutoSchema): - writable_serializers = {} - - def get_operation_id(self, operation_keys=None): - operation_keys = operation_keys or self.operation_keys - operation_id = self.overrides.get('operation_id', '') - if not operation_id: - # Overwrite the action for bulk update/bulk delete views to ensure they get an operation ID that's - # unique from their single-object counterparts (see #3436) - if operation_keys[-1] in ('delete', 'partial_update', 'update') and not getattr(self.view, 'detail', None): - operation_keys[-1] = f'bulk_{operation_keys[-1]}' - operation_id = '_'.join(operation_keys) - - return operation_id - - def get_request_serializer(self): - serializer = super().get_request_serializer() - - if serializer is not None and not isinstance(serializer, openapi.Schema) and self.method in self.implicit_body_methods: - if writable_class := self.get_writable_class(serializer): - if hasattr(serializer, 'child'): - child_serializer = self.get_writable_class(serializer.child) - serializer = writable_class(context=serializer.context, child=child_serializer) - else: - serializer = writable_class(context=serializer.context) - return serializer - - def get_writable_class(self, serializer): - properties = {} - fields = {} if hasattr(serializer, 'child') else serializer.fields - for child_name, child in fields.items(): - if isinstance(child, (ChoiceField, WritableNestedSerializer)): - properties[child_name] = None - elif isinstance(child, ManyRelatedField) and isinstance(child.child_relation, SerializedPKRelatedField): - properties[child_name] = None - - if properties: - if type(serializer) not in self.writable_serializers: - writable_name = 'Writable' + type(serializer).__name__ - meta_class = getattr(type(serializer), 'Meta', None) - if meta_class: - ref_name = 'Writable' + get_serializer_ref_name(serializer) - writable_meta = type('Meta', (meta_class,), {'ref_name': ref_name}) - properties['Meta'] = writable_meta - - self.writable_serializers[type(serializer)] = type(writable_name, (type(serializer),), properties) - - writable_class = self.writable_serializers[type(serializer)] - return writable_class - - -class SerializedPKRelatedFieldInspector(FieldInspector): - def field_to_swagger_object(self, field, swagger_object_type, use_references, **kwargs): - SwaggerType, ChildSwaggerType = self._get_partial_types(field, swagger_object_type, use_references, **kwargs) - if isinstance(field, SerializedPKRelatedField): - return self.probe_field_inspectors(field.serializer(), ChildSwaggerType, use_references) - - return NotHandled - - -class ChoiceFieldInspector(FieldInspector): - def field_to_swagger_object(self, field, swagger_object_type, use_references, **kwargs): - # this returns a callable which extracts title, description and other stuff - # https://drf-yasg.readthedocs.io/en/stable/_modules/drf_yasg/inspectors/base.html#FieldInspector._get_partial_types - SwaggerType, _ = self._get_partial_types(field, swagger_object_type, use_references, **kwargs) - - if isinstance(field, ChoiceField): - choices = field._choices - choice_value = list(choices.keys()) - choice_label = list(choices.values()) - value_schema = openapi.Schema(type=openapi.TYPE_STRING, enum=choice_value) - - if set([None] + choice_value) == {None, True, False}: - # DeviceType.subdevice_role and Device.face need to be differentiated since they each have - # subtly different values in their choice keys. - # - subdevice_role and connection_status are booleans, although subdevice_role includes None - # - face is an integer set {0, 1} which is easily confused with {False, True} - schema_type = openapi.TYPE_STRING - if all(type(x) == bool for x in [c for c in choice_value if c is not None]): - schema_type = openapi.TYPE_BOOLEAN - value_schema = openapi.Schema(type=schema_type, enum=choice_value) - value_schema['x-nullable'] = True - - if all(type(x) == int for x in [c for c in choice_value if c is not None]): - # Change value_schema for IPAddressFamilyChoices, RackWidthChoices - value_schema = openapi.Schema(type=openapi.TYPE_INTEGER, enum=choice_value) - - schema = SwaggerType(type=openapi.TYPE_OBJECT, required=["label", "value"], properties={ - "label": openapi.Schema(type=openapi.TYPE_STRING, enum=choice_label), - "value": value_schema - }) - - return schema - - return NotHandled - - -class NullableBooleanFieldInspector(FieldInspector): - def process_result(self, result, method_name, obj, **kwargs): - - if isinstance(result, openapi.Schema) and isinstance(obj, ChoiceField) and result.type == 'boolean': - keys = obj.choices.keys() - if set(keys) == {None, True, False}: - result['x-nullable'] = True - result.type = 'boolean' - - return result - - -class CustomFieldsDataFieldInspector(FieldInspector): - - def field_to_swagger_object(self, field, swagger_object_type, use_references, **kwargs): - SwaggerType, ChildSwaggerType = self._get_partial_types(field, swagger_object_type, use_references, **kwargs) - - if isinstance(field, CustomFieldsDataField) and swagger_object_type == openapi.Schema: - return SwaggerType(type=openapi.TYPE_OBJECT) - - return NotHandled - - -class NullablePaginatorInspector(PaginatorInspector): - def process_result(self, result, method_name, obj, **kwargs): - if method_name == 'get_paginated_response' and isinstance(result, openapi.Schema): - next = result.properties['next'] - if isinstance(next, openapi.Schema): - next['x-nullable'] = True - previous = result.properties['previous'] - if isinstance(previous, openapi.Schema): - previous['x-nullable'] = True - - return result diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py index 76ff8192cb..cfe21063b4 100644 --- a/netbox/utilities/filters.py +++ b/netbox/utilities/filters.py @@ -3,6 +3,8 @@ from django.conf import settings from django.core.exceptions import ValidationError from django_filters.constants import EMPTY_VALUES +from drf_spectacular.utils import extend_schema_field +from drf_spectacular.types import OpenApiTypes def multivalue_field_factory(field_class): @@ -37,26 +39,32 @@ def validate(self, value): # Filters # +@extend_schema_field(OpenApiTypes.STR) class MultiValueCharFilter(django_filters.MultipleChoiceFilter): field_class = multivalue_field_factory(forms.CharField) +@extend_schema_field(OpenApiTypes.DATE) class MultiValueDateFilter(django_filters.MultipleChoiceFilter): field_class = multivalue_field_factory(forms.DateField) +@extend_schema_field(OpenApiTypes.DATETIME) class MultiValueDateTimeFilter(django_filters.MultipleChoiceFilter): field_class = multivalue_field_factory(forms.DateTimeField) +@extend_schema_field(OpenApiTypes.INT32) class MultiValueNumberFilter(django_filters.MultipleChoiceFilter): field_class = multivalue_field_factory(forms.IntegerField) +@extend_schema_field(OpenApiTypes.DECIMAL) class MultiValueDecimalFilter(django_filters.MultipleChoiceFilter): field_class = multivalue_field_factory(forms.DecimalField) +@extend_schema_field(OpenApiTypes.TIME) class MultiValueTimeFilter(django_filters.MultipleChoiceFilter): field_class = multivalue_field_factory(forms.TimeField) @@ -65,6 +73,7 @@ class MACAddressFilter(django_filters.CharFilter): pass +@extend_schema_field(OpenApiTypes.STR) class MultiValueMACAddressFilter(django_filters.MultipleChoiceFilter): field_class = multivalue_field_factory(forms.CharField) @@ -75,6 +84,7 @@ def filter(self, qs, value): return qs.none() +@extend_schema_field(OpenApiTypes.STR) class MultiValueWWNFilter(django_filters.MultipleChoiceFilter): field_class = multivalue_field_factory(forms.CharField) diff --git a/netbox/utilities/tests/test_api.py b/netbox/utilities/tests/test_api.py index e341442be7..1cc3487b1a 100644 --- a/netbox/utilities/tests/test_api.py +++ b/netbox/utilities/tests/test_api.py @@ -249,9 +249,9 @@ def setUp(self): def test_api_docs(self): url = reverse('api_docs') - params = { - "format": "openapi", - } + response = self.client.get(url) + self.assertEqual(response.status_code, 200) - response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) + url = reverse('schema') + response = self.client.get(url) self.assertEqual(response.status_code, 200) diff --git a/netbox/virtualization/api/nested_serializers.py b/netbox/virtualization/api/nested_serializers.py index 07a9f5d138..8c3f57c1d5 100644 --- a/netbox/virtualization/api/nested_serializers.py +++ b/netbox/virtualization/api/nested_serializers.py @@ -1,3 +1,4 @@ +from drf_spectacular.utils import extend_schema_serializer from rest_framework import serializers from netbox.api.serializers import WritableNestedSerializer @@ -16,6 +17,9 @@ # +@extend_schema_serializer( + exclude_fields=('cluster_count',), +) class NestedClusterTypeSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustertype-detail') cluster_count = serializers.IntegerField(read_only=True) @@ -25,6 +29,9 @@ class Meta: fields = ['id', 'url', 'display', 'name', 'slug', 'cluster_count'] +@extend_schema_serializer( + exclude_fields=('cluster_count',), +) class NestedClusterGroupSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustergroup-detail') cluster_count = serializers.IntegerField(read_only=True) @@ -34,6 +41,9 @@ class Meta: fields = ['id', 'url', 'display', 'name', 'slug', 'cluster_count'] +@extend_schema_serializer( + exclude_fields=('virtualmachine_count',), +) class NestedClusterSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:cluster-detail') virtualmachine_count = serializers.IntegerField(read_only=True) diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index 2fd52146ce..e53ce164e5 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -1,4 +1,4 @@ -from drf_yasg.utils import swagger_serializer_method +from drf_spectacular.utils import extend_schema_field from rest_framework import serializers from dcim.api.nested_serializers import ( @@ -100,7 +100,7 @@ class Meta(VirtualMachineSerializer.Meta): 'local_context_data', 'tags', 'custom_fields', 'config_context', 'created', 'last_updated', ] - @swagger_serializer_method(serializer_or_field=serializers.JSONField) + @extend_schema_field(serializers.JSONField(allow_null=True)) def get_config_context(self, obj): return obj.get_config_context() @@ -114,7 +114,7 @@ class VMInterfaceSerializer(NetBoxModelSerializer): virtual_machine = NestedVirtualMachineSerializer() parent = NestedVMInterfaceSerializer(required=False, allow_null=True) bridge = NestedVMInterfaceSerializer(required=False, allow_null=True) - mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False) + mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False, allow_null=True) untagged_vlan = NestedVLANSerializer(required=False, allow_null=True) tagged_vlans = SerializedPKRelatedField( queryset=VLAN.objects.all(), @@ -123,9 +123,10 @@ class VMInterfaceSerializer(NetBoxModelSerializer): many=True ) vrf = NestedVRFSerializer(required=False, allow_null=True) - l2vpn_termination = NestedL2VPNTerminationSerializer(read_only=True) + l2vpn_termination = NestedL2VPNTerminationSerializer(read_only=True, allow_null=True) count_ipaddresses = serializers.IntegerField(read_only=True) count_fhrp_groups = serializers.IntegerField(read_only=True) + mac_address = serializers.CharField(required=False, default=None) class Meta: model = VMInterface diff --git a/netbox/wireless/api/nested_serializers.py b/netbox/wireless/api/nested_serializers.py index 0e84042662..53f2a63541 100644 --- a/netbox/wireless/api/nested_serializers.py +++ b/netbox/wireless/api/nested_serializers.py @@ -1,3 +1,4 @@ +from drf_spectacular.utils import extend_schema_serializer from rest_framework import serializers from netbox.api.serializers import WritableNestedSerializer @@ -10,6 +11,9 @@ ) +@extend_schema_serializer( + exclude_fields=('wirelesslan_count',), +) class NestedWirelessLANGroupSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='wireless-api:wirelesslangroup-detail') wirelesslan_count = serializers.IntegerField(read_only=True) diff --git a/requirements.txt b/requirements.txt index e12b31216e..e557f88c2c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,7 +15,7 @@ django-tables2==2.5.3 django-taggit==3.1.0 django-timezone-field==5.0 djangorestframework==3.14.0 -drf-yasg[validation]==1.21.5 +drf-spectacular==0.25.1 feedparser==6.0.10 graphene-django==3.0.0 gunicorn==20.1.0