diff --git a/base_requirements.txt b/base_requirements.txt index bf03bf71e..80f08f894 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -18,6 +18,10 @@ django-debug-toolbar # https://github.com/carltongibson/django-filter django-filter +# Django debug toolbar extension with support for GraphiQL +# https://github.com/flavors/django-graphiql-debug-toolbar/ +django-graphiql-debug-toolbar + # Modified Preorder Tree Traversal (recursive nesting of objects) # https://github.com/django-mptt/django-mptt django-mptt @@ -54,6 +58,10 @@ djangorestframework # https://github.com/axnsan12/drf-yasg drf-yasg[validation] +# Django wrapper for Graphene (GraphQL support) +# https://github.com/graphql-python/graphene-django +graphene_django + # WSGI HTTP server # https://gunicorn.org/ gunicorn diff --git a/netbox/circuits/graphql/schema.py b/netbox/circuits/graphql/schema.py new file mode 100644 index 000000000..5a3f42270 --- /dev/null +++ b/netbox/circuits/graphql/schema.py @@ -0,0 +1,21 @@ +import graphene + +from netbox.graphql.fields import ObjectField, ObjectListField +from .types import * + + +class CircuitsQuery(graphene.ObjectType): + circuit = ObjectField(CircuitType) + circuits = ObjectListField(CircuitType) + + circuit_termination = ObjectField(CircuitTerminationType) + circuit_terminations = ObjectListField(CircuitTerminationType) + + circuit_type = ObjectField(CircuitTypeType) + circuit_types = ObjectListField(CircuitTypeType) + + provider = ObjectField(ProviderType) + providers = ObjectListField(ProviderType) + + provider_network = ObjectField(ProviderNetworkType) + provider_networks = ObjectListField(ProviderNetworkType) diff --git a/netbox/circuits/graphql/types.py b/netbox/circuits/graphql/types.py new file mode 100644 index 000000000..51ef8082b --- /dev/null +++ b/netbox/circuits/graphql/types.py @@ -0,0 +1,54 @@ +from circuits import filtersets, models +from netbox.graphql.types import * + +__all__ = ( + 'CircuitType', + 'CircuitTerminationType', + 'CircuitTypeType', + 'ProviderType', + 'ProviderNetworkType', +) + + +# +# Object types +# + +class ProviderType(TaggedObjectType): + + class Meta: + model = models.Provider + fields = '__all__' + filterset_class = filtersets.ProviderFilterSet + + +class ProviderNetworkType(TaggedObjectType): + + class Meta: + model = models.ProviderNetwork + fields = '__all__' + filterset_class = filtersets.ProviderNetworkFilterSet + + +class CircuitType(TaggedObjectType): + + class Meta: + model = models.Circuit + fields = '__all__' + filterset_class = filtersets.CircuitFilterSet + + +class CircuitTypeType(ObjectType): + + class Meta: + model = models.CircuitType + fields = '__all__' + filterset_class = filtersets.CircuitTypeFilterSet + + +class CircuitTerminationType(BaseObjectType): + + class Meta: + model = models.CircuitTermination + fields = '__all__' + filterset_class = filtersets.CircuitTerminationFilterSet diff --git a/netbox/netbox/graphql/__init__.py b/netbox/netbox/graphql/__init__.py new file mode 100644 index 000000000..6316a2390 --- /dev/null +++ b/netbox/netbox/graphql/__init__.py @@ -0,0 +1,11 @@ +import graphene +from graphene_django.converter import convert_django_field +from taggit.managers import TaggableManager + + +@convert_django_field.register(TaggableManager) +def convert_field_to_tags_list(field, registry=None): + """ + Register conversion handler for django-taggit's TaggableManager + """ + return graphene.List(graphene.String) diff --git a/netbox/netbox/graphql/fields.py b/netbox/netbox/graphql/fields.py new file mode 100644 index 000000000..e3ef39f4a --- /dev/null +++ b/netbox/netbox/graphql/fields.py @@ -0,0 +1,65 @@ +from functools import partial + +import graphene +from graphene_django import DjangoListField + +from .utils import get_graphene_type + +__all__ = ( + 'ObjectField', + 'ObjectListField', +) + + +class ObjectField(graphene.Field): + """ + Retrieve a single object, identified by its numeric ID. + """ + def __init__(self, *args, **kwargs): + + if 'id' not in kwargs: + kwargs['id'] = graphene.Int(required=True) + + super().__init__(*args, **kwargs) + + @staticmethod + def object_resolver(django_object_type, root, info, **args): + """ + Return an object given its numeric ID. + """ + manager = django_object_type._meta.model._default_manager + queryset = django_object_type.get_queryset(manager, info) + + return queryset.get(**args) + + def get_resolver(self, parent_resolver): + return partial(self.object_resolver, self._type) + + +class ObjectListField(DjangoListField): + """ + Retrieve a list of objects, optionally filtered by one or more FilterSet filters. + """ + def __init__(self, _type, *args, **kwargs): + + assert hasattr(_type._meta, 'filterset_class'), "DjangoFilterListField must define filterset_class under Meta" + filterset_class = _type._meta.filterset_class + + # Get FilterSet kwargs + filter_kwargs = {} + for filter_name, filter_field in filterset_class.get_filters().items(): + field_type = get_graphene_type(type(filter_field)) + filter_kwargs[filter_name] = graphene.Argument(field_type) + + super().__init__(_type, args=filter_kwargs, *args, **kwargs) + + @staticmethod + def list_resolver(django_object_type, resolver, default_manager, root, info, **args): + # Get the QuerySet from the object type + queryset = django_object_type.get_queryset(default_manager, info) + + # Instantiate and apply the FilterSet + filterset_class = django_object_type._meta.filterset_class + filterset = filterset_class(data=args, queryset=queryset, request=info.context) + + return filterset.qs diff --git a/netbox/netbox/graphql/schema.py b/netbox/netbox/graphql/schema.py new file mode 100644 index 000000000..04d836988 --- /dev/null +++ b/netbox/netbox/graphql/schema.py @@ -0,0 +1,13 @@ +import graphene + +from circuits.graphql.schema import CircuitsQuery + + +class Query( + CircuitsQuery, + graphene.ObjectType +): + pass + + +schema = graphene.Schema(query=Query, auto_camelcase=False) diff --git a/netbox/netbox/graphql/types.py b/netbox/netbox/graphql/types.py new file mode 100644 index 000000000..7ec152fac --- /dev/null +++ b/netbox/netbox/graphql/types.py @@ -0,0 +1,41 @@ +import graphene +from graphene_django import DjangoObjectType + +__all__ = ( + 'BaseObjectType', + 'ObjectType', + 'TaggedObjectType', +) + + +class BaseObjectType(DjangoObjectType): + """ + Base GraphQL object type for all NetBox objects + """ + class Meta: + abstract = True + + @classmethod + def get_queryset(cls, queryset, info): + # Enforce object permissions on the queryset + return queryset.restrict(info.context.user, 'view') + + +class ObjectType(BaseObjectType): + # TODO: Custom fields support + + class Meta: + abstract = True + + +class TaggedObjectType(ObjectType): + """ + Extends ObjectType with support for Tags + """ + tags = graphene.List(graphene.String) + + class Meta: + abstract = True + + def resolve_tags(self, info): + return self.tags.all() diff --git a/netbox/netbox/graphql/utils.py b/netbox/netbox/graphql/utils.py new file mode 100644 index 000000000..c71d49204 --- /dev/null +++ b/netbox/netbox/graphql/utils.py @@ -0,0 +1,25 @@ +import graphene +from django_filters import filters + + +def get_graphene_type(filter_cls): + """ + Return the appropriate Graphene scalar type for a django_filters Filter + """ + if issubclass(filter_cls, filters.BooleanFilter): + field_type = graphene.Boolean + elif issubclass(filter_cls, filters.NumberFilter): + # TODO: Floats? BigInts? + field_type = graphene.Int + elif issubclass(filter_cls, filters.DateFilter): + field_type = graphene.Date + elif issubclass(filter_cls, filters.DateTimeFilter): + field_type = graphene.DateTime + else: + field_type = graphene.String + + # Multi-value filters should be handled as lists + if issubclass(filter_cls, filters.MultipleChoiceFilter): + return graphene.List(field_type) + + return field_type diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index c3fa08e15..2552bb76f 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -282,9 +282,11 @@ INSTALLED_APPS = [ 'cacheops', 'corsheaders', 'debug_toolbar', + 'graphiql_debug_toolbar', 'django_filters', 'django_tables2', 'django_prometheus', + 'graphene_django', 'mptt', 'rest_framework', 'taggit', @@ -303,7 +305,7 @@ INSTALLED_APPS = [ # Middleware MIDDLEWARE = [ - 'debug_toolbar.middleware.DebugToolbarMiddleware', + 'graphiql_debug_toolbar.middleware.DebugToolbarMiddleware', 'django_prometheus.middleware.PrometheusBeforeMiddleware', 'corsheaders.middleware.CorsMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index 54d002d5d..9257f12b9 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -4,9 +4,11 @@ from django.urls import path, re_path from django.views.static import serve from drf_yasg import openapi from drf_yasg.views import get_schema_view +from graphene_django.views import GraphQLView from extras.plugins.urls import plugin_admin_patterns, plugin_patterns, plugin_api_patterns from netbox.api.views import APIRootView, StatusView +from netbox.graphql.schema import schema from netbox.views import HomeView, StaticMediaFailureView, SearchView from users.views import LoginView, LogoutView from .admin import admin_site @@ -60,6 +62,9 @@ _patterns = [ path('api/redoc/', schema_view.with_ui('redoc'), name='api_redocs'), re_path(r'^api/swagger(?P.json|.yaml)$', schema_view.without_ui(), name='schema_swagger'), + # GraphQL + path('graphql/', GraphQLView.as_view(graphiql=True, schema=schema)), + # Serving static media in Django to pipe it through LoginRequiredMiddleware path('media/', serve, {'document_root': settings.MEDIA_ROOT}), path('media-failure/', StaticMediaFailureView.as_view(), name='media_failure'), diff --git a/netbox/project-static/volt b/netbox/project-static/volt new file mode 160000 index 000000000..942aa8c7b --- /dev/null +++ b/netbox/project-static/volt @@ -0,0 +1 @@ +Subproject commit 942aa8c7bd506fb88b7c669cab173bc319eca309 diff --git a/requirements.txt b/requirements.txt index 467a48542..48fe53543 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,7 @@ django-cacheops==6.0 django-cors-headers==3.7.0 django-debug-toolbar==3.2.1 django-filter==2.4.0 +django-graphiql-debug-toolbar==0.1.4 django-mptt==0.12.0 django-pglocks==1.0.4 django-prometheus==2.1.0 @@ -12,6 +13,7 @@ django-taggit==1.4.0 django-timezone-field==4.1.2 djangorestframework==3.12.4 drf-yasg[validation]==1.20.0 +graphene_django==2.15.0 gunicorn==20.1.0 Jinja2==3.0.1 Markdown==3.3.4