From b83de7eb1183c68697fa7aa6f2179c40a4c1344e Mon Sep 17 00:00:00 2001 From: Dave Cameron Date: Thu, 22 Feb 2018 17:46:50 -0500 Subject: [PATCH 1/4] Use drf_yasg to generate swagger drf_yasg provides more complete swagger output, allowing for generation of usable clients. Some custom work was needed to accommodate Netbox's custom field serializers, and to provide x-nullable attributes where appropriate. --- netbox/netbox/settings.py | 24 ++++++++++- netbox/netbox/urls.py | 20 +++++++-- netbox/utilities/custom_inspectors.py | 60 +++++++++++++++++++++++++++ old_requirements.txt | 1 + requirements.txt | 2 +- 5 files changed, 102 insertions(+), 5 deletions(-) create mode 100644 netbox/utilities/custom_inspectors.py diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index c531231b1..b326a7fbc 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -133,7 +133,6 @@ INSTALLED_APPS = ( 'django_tables2', 'mptt', 'rest_framework', - 'rest_framework_swagger', 'timezone_field', 'circuits', 'dcim', @@ -144,6 +143,7 @@ INSTALLED_APPS = ( 'users', 'utilities', 'virtualization', + 'drf_yasg', ) # Middleware @@ -246,6 +246,28 @@ REST_FRAMEWORK = { 'VIEW_NAME_FUNCTION': 'netbox.api.get_view_name', } +# drf_yasg settings for Swagger +SWAGGER_SETTINGS = { + 'DEFAULT_FIELD_INSPECTORS': [ + 'utilities.custom_inspectors.NullableBooleanFieldInspector', + 'utilities.custom_inspectors.CustomChoiceFieldInspector', + '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.SimpleFieldInspector', + 'drf_yasg.inspectors.StringDefaultFieldInspector', + ], + 'DEFAULT_PAGINATOR_INSPECTORS': [ + 'utilities.custom_inspectors.NullablePaginatorInspector', + 'drf_yasg.inspectors.DjangoRestResponsePagination', + 'drf_yasg.inspectors.CoreAPICompatInspector', + ] +} + + # Django debug toolbar INTERNAL_IPS = ( '127.0.0.1', diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index 6cd7a9e8d..5f7b26a71 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -4,12 +4,24 @@ from django.conf import settings from django.conf.urls import include, url from django.contrib import admin from django.views.static import serve -from rest_framework_swagger.views import get_swagger_view +from drf_yasg.views import get_schema_view +from drf_yasg import openapi from netbox.views import APIRootView, HomeView, SearchView from users.views import LoginView, LogoutView -swagger_view = get_swagger_view(title='NetBox API') +schema_view = get_schema_view( + openapi.Info( + title="NetBox API", + default_version='v2', + description="API to access NetBox", + terms_of_service="https://github.com/digitalocean/netbox", + contact=openapi.Contact(email="netbox@digitalocean.com"), + license=openapi.License(name="Apache v2 License"), + ), + validators=['flex', 'ssv'], + public=True, +) _patterns = [ @@ -40,7 +52,9 @@ _patterns = [ url(r'^api/secrets/', include('secrets.api.urls')), url(r'^api/tenancy/', include('tenancy.api.urls')), url(r'^api/virtualization/', include('virtualization.api.urls')), - url(r'^api/docs/', swagger_view, name='api_docs'), + url(r'^api/docs/$', schema_view.with_ui('swagger', cache_timeout=None), name='api_docs'), + url(r'^api/redoc/$', schema_view.with_ui('redoc', cache_timeout=None), name='api_redocs'), + url(r'^api/swagger(?P.json|.yaml)$', schema_view.without_ui(cache_timeout=None), name='schema_swagger'), # Serving static media in Django to pipe it through LoginRequiredMiddleware url(r'^media/(?P.*)$', serve, {'document_root': settings.MEDIA_ROOT}), diff --git a/netbox/utilities/custom_inspectors.py b/netbox/utilities/custom_inspectors.py new file mode 100644 index 000000000..58a5a4739 --- /dev/null +++ b/netbox/utilities/custom_inspectors.py @@ -0,0 +1,60 @@ +from drf_yasg import openapi +from drf_yasg.inspectors import FieldInspector, NotHandled, PaginatorInspector +from rest_framework.fields import ChoiceField + +from extras.api.customfields import CustomFieldsSerializer +from utilities.api import ChoiceFieldSerializer + + +class CustomChoiceFieldInspector(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, ChoiceFieldSerializer): + value_schema = openapi.Schema(type=openapi.TYPE_INTEGER) + + if set([None] + list(field._choices.keys())) == {None, True, False}: + # Special case face and connection_status because the only keys for choices are True and False, + # but the underlying field is still a NullBooleanField + value_schema = openapi.Schema(type=openapi.TYPE_BOOLEAN) + value_schema['x-nullable'] = True + + schema = SwaggerType(type=openapi.TYPE_OBJECT, required=["label", "value"], properties={ + "label": openapi.Schema(type=openapi.TYPE_STRING), + "value": value_schema + }) + + return schema + + elif isinstance(field, CustomFieldsSerializer): + schema = SwaggerType(type=openapi.TYPE_OBJECT) + 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 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/old_requirements.txt b/old_requirements.txt index 610ec6c44..b3f7b3c47 100644 --- a/old_requirements.txt +++ b/old_requirements.txt @@ -1,2 +1,3 @@ +django-rest-swagger psycopg2 pycrypto diff --git a/requirements.txt b/requirements.txt index 89c880815..11b4d4773 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,10 +3,10 @@ django-cors-headers>=2.1.0 django-debug-toolbar>=1.9.0 django-filter>=1.1.0 django-mptt>=0.9.0 -django-rest-swagger>=2.1.0 django-tables2>=1.19.0 django-timezone-field>=2.0 djangorestframework>=3.7.7 +drf-yasg>=1.4.4 graphviz>=0.8.2 Markdown>=2.6.11 natsort>=5.2.0 From 53e4e749307e98834786065fdc6ad844f8c9794c Mon Sep 17 00:00:00 2001 From: Dave Cameron Date: Thu, 8 Mar 2018 19:26:41 -0500 Subject: [PATCH 2/4] Differentiate better between boolean and 0, 1 choices --- netbox/utilities/custom_inspectors.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/netbox/utilities/custom_inspectors.py b/netbox/utilities/custom_inspectors.py index 58a5a4739..25330ad67 100644 --- a/netbox/utilities/custom_inspectors.py +++ b/netbox/utilities/custom_inspectors.py @@ -15,10 +15,16 @@ class CustomChoiceFieldInspector(FieldInspector): if isinstance(field, ChoiceFieldSerializer): value_schema = openapi.Schema(type=openapi.TYPE_INTEGER) - if set([None] + list(field._choices.keys())) == {None, True, False}: - # Special case face and connection_status because the only keys for choices are True and False, - # but the underlying field is still a NullBooleanField - value_schema = openapi.Schema(type=openapi.TYPE_BOOLEAN) + choices = list(field._choices.keys()) + if set([None] + choices) == {None, True, False}: + # DeviceType.subdevice_role, Device.face and InterfaceConnection.connection_status all 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_INTEGER + if all(type(x) == bool for x in [c for c in choices if c is not None]): + schema_type = openapi.TYPE_BOOLEAN + value_schema = openapi.Schema(type=schema_type) value_schema['x-nullable'] = True schema = SwaggerType(type=openapi.TYPE_OBJECT, required=["label", "value"], properties={ From e071b7dfd53ca74f2b1dda5f93682d146d79bd13 Mon Sep 17 00:00:00 2001 From: Dave Cameron Date: Thu, 15 Mar 2018 16:51:57 -0400 Subject: [PATCH 3/4] The id__in field is a csv-separated string of ids drf_yasg is interpreting it as a number because NumericInFilter inherits from django's NumberFilter which explicitly identifies as being a DecimalField. --- netbox/netbox/settings.py | 4 ++++ netbox/utilities/custom_inspectors.py | 12 +++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index b326a7fbc..a296dbbd0 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -260,6 +260,10 @@ SWAGGER_SETTINGS = { 'drf_yasg.inspectors.SimpleFieldInspector', 'drf_yasg.inspectors.StringDefaultFieldInspector', ], + 'DEFAULT_FILTER_INSPECTORS': [ + 'utilities.custom_inspectors.IdInFilterInspector', + 'drf_yasg.inspectors.CoreAPICompatInspector', + ], 'DEFAULT_PAGINATOR_INSPECTORS': [ 'utilities.custom_inspectors.NullablePaginatorInspector', 'drf_yasg.inspectors.DjangoRestResponsePagination', diff --git a/netbox/utilities/custom_inspectors.py b/netbox/utilities/custom_inspectors.py index 25330ad67..b97506b85 100644 --- a/netbox/utilities/custom_inspectors.py +++ b/netbox/utilities/custom_inspectors.py @@ -1,5 +1,5 @@ from drf_yasg import openapi -from drf_yasg.inspectors import FieldInspector, NotHandled, PaginatorInspector +from drf_yasg.inspectors import FieldInspector, NotHandled, PaginatorInspector, FilterInspector from rest_framework.fields import ChoiceField from extras.api.customfields import CustomFieldsSerializer @@ -53,6 +53,16 @@ class NullableBooleanFieldInspector(FieldInspector): return result +class IdInFilterInspector(FilterInspector): + def process_result(self, result, method_name, obj, **kwargs): + if isinstance(result, list): + params = [p for p in result if isinstance(p, openapi.Parameter) and p.name == 'id__in'] + for p in params: + p.type = 'string' + + return result + + class NullablePaginatorInspector(PaginatorInspector): def process_result(self, result, method_name, obj, **kwargs): if method_name == 'get_paginated_response' and isinstance(result, openapi.Schema): From b5f1d74d6feb800b0bdd11ba95aaf1b9c82afcbe Mon Sep 17 00:00:00 2001 From: Dave Cameron Date: Fri, 16 Mar 2018 16:48:08 -0400 Subject: [PATCH 4/4] Definition for /dcim/connected-device/ endpoint --- netbox/dcim/api/views.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 12e657e79..13f68639f 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -6,6 +6,9 @@ from django.conf import settings from django.db import transaction from django.http import HttpResponseBadRequest, HttpResponseForbidden 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 rest_framework.decorators import detail_route from rest_framework.mixins import ListModelMixin from rest_framework.response import Response @@ -418,14 +421,20 @@ class ConnectedDeviceViewSet(ViewSet): * `peer-interface`: The name of the peer interface """ permission_classes = [IsAuthenticatedOrLoginNotRequired] + _device_param = Parameter('peer-device', 'query', + description='The name of the peer device', required=True, type=openapi.TYPE_STRING) + _interface_param = Parameter('peer-interface', 'query', + description='The name of the peer interface', required=True, type=openapi.TYPE_STRING) def get_view_name(self): return "Connected Device Locator" + @swagger_auto_schema( + manual_parameters=[_device_param, _interface_param], responses={'200': serializers.DeviceSerializer}) def list(self, request): - peer_device_name = request.query_params.get('peer-device') - peer_interface_name = request.query_params.get('peer-interface') + peer_device_name = request.query_params.get(self._device_param.name) + peer_interface_name = request.query_params.get(self._interface_param.name) if not peer_device_name or not peer_interface_name: raise MissingFilterException(detail='Request must include "peer-device" and "peer-interface" filters.')