Browse Source

Merge pull request #6678 from netbox-community/2007-graphql

Closes #2007: Implement GraphQL API
Jeremy Stretch 4 years ago
parent
commit
57fc6efd4c
45 changed files with 1456 additions and 10 deletions
  1. 8 0
      base_requirements.txt
  2. 8 0
      docs/configuration/optional-settings.md
  3. 70 0
      docs/graphql-api/overview.md
  4. 2 0
      mkdocs.yml
  5. 0 0
      netbox/circuits/graphql/__init__.py
  6. 21 0
      netbox/circuits/graphql/schema.py
  7. 50 0
      netbox/circuits/graphql/types.py
  8. 0 0
      netbox/dcim/graphql/__init__.py
  9. 105 0
      netbox/dcim/graphql/schema.py
  10. 353 0
      netbox/dcim/graphql/types.py
  11. 1 1
      netbox/dcim/models/__init__.py
  12. 0 0
      netbox/extras/graphql/__init__.py
  13. 30 0
      netbox/extras/graphql/schema.py
  14. 77 0
      netbox/extras/graphql/types.py
  15. 2 1
      netbox/extras/tests/test_api.py
  16. 0 0
      netbox/ipam/graphql/__init__.py
  17. 36 0
      netbox/ipam/graphql/schema.py
  18. 98 0
      netbox/ipam/graphql/types.py
  19. 4 0
      netbox/netbox/api/exceptions.py
  20. 3 0
      netbox/netbox/configuration.example.py
  21. 22 0
      netbox/netbox/graphql/__init__.py
  22. 65 0
      netbox/netbox/graphql/fields.py
  23. 25 0
      netbox/netbox/graphql/schema.py
  24. 64 0
      netbox/netbox/graphql/types.py
  25. 25 0
      netbox/netbox/graphql/utils.py
  26. 40 0
      netbox/netbox/graphql/views.py
  27. 2 1
      netbox/netbox/middleware.py
  28. 15 1
      netbox/netbox/settings.py
  29. 36 0
      netbox/netbox/tests/test_graphql.py
  30. 5 0
      netbox/netbox/urls.py
  31. 1 0
      netbox/project-static/volt
  32. 0 0
      netbox/tenancy/graphql/__init__.py
  33. 12 0
      netbox/tenancy/graphql/schema.py
  34. 23 0
      netbox/tenancy/graphql/types.py
  35. 0 0
      netbox/users/graphql/__init__.py
  36. 12 0
      netbox/users/graphql/schema.py
  37. 37 0
      netbox/users/graphql/types.py
  38. 16 2
      netbox/users/tests/test_api.py
  39. 17 2
      netbox/utilities/api.py
  40. 94 2
      netbox/utilities/testing/api.py
  41. 0 0
      netbox/virtualization/graphql/__init__.py
  42. 21 0
      netbox/virtualization/graphql/schema.py
  43. 53 0
      netbox/virtualization/graphql/types.py
  44. 1 0
      netbox/virtualization/tests/test_api.py
  45. 2 0
      requirements.txt

+ 8 - 0
base_requirements.txt

@@ -18,6 +18,10 @@ django-debug-toolbar
 # https://github.com/carltongibson/django-filter
 # https://github.com/carltongibson/django-filter
 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)
 # Modified Preorder Tree Traversal (recursive nesting of objects)
 # https://github.com/django-mptt/django-mptt
 # https://github.com/django-mptt/django-mptt
 django-mptt
 django-mptt
@@ -54,6 +58,10 @@ djangorestframework
 # https://github.com/axnsan12/drf-yasg
 # https://github.com/axnsan12/drf-yasg
 drf-yasg[validation]
 drf-yasg[validation]
 
 
+# Django wrapper for Graphene (GraphQL support)
+# https://github.com/graphql-python/graphene-django
+graphene_django
+
 # WSGI HTTP server
 # WSGI HTTP server
 # https://gunicorn.org/
 # https://gunicorn.org/
 gunicorn
 gunicorn

+ 8 - 0
docs/configuration/optional-settings.md

@@ -201,6 +201,14 @@ EXEMPT_VIEW_PERMISSIONS = ['*']
 
 
 ---
 ---
 
 
+## GRAPHQL_ENABLED
+
+Default: True
+
+Setting this to False will disable the GraphQL API.
+
+---
+
 ## HTTP_PROXIES
 ## HTTP_PROXIES
 
 
 Default: None
 Default: None

+ 70 - 0
docs/graphql-api/overview.md

@@ -0,0 +1,70 @@
+# GraphQL API Overview
+
+NetBox provides a read-only [GraphQL](https://graphql.org/) API to complement its REST API. This API is powered by the [Graphene](https://graphene-python.org/) library and [Graphene-Django](https://docs.graphene-python.org/projects/django/en/latest/).
+
+## Queries
+
+GraphQL enables the client to specify an arbitrary nested list of fields to include in the response. All queries are made to the root `/graphql` API endpoint. For example, to return the circuit ID and provider name of each circuit with an active status, you can issue a request such as the following:
+
+```
+curl -H "Authorization: Token $TOKEN" \
+-H "Content-Type: application/json" \
+-H "Accept: application/json" \
+http://netbox/graphql/ \
+--data '{"query": "query {circuits(status:\"active\" {cid provider {name}}}"}'
+```
+
+The response will include the requested data formatted as JSON:
+
+```json
+{
+  "data": {
+    "circuits": [
+      {
+        "cid": "1002840283",
+        "provider": {
+          "name": "CenturyLink"
+        }
+      },
+      {
+        "cid": "1002840457",
+        "provider": {
+          "name": "CenturyLink"
+        }
+      }
+    ]
+  }
+}
+```
+
+!!! note
+    It's recommended to pass the return data through a JSON parser such as `jq` for better readability.
+
+NetBox provides both a singular and plural query field for each object type:
+
+* `$OBJECT`: Returns a single object. Must specify the object's unique ID as `(id: 123)`.
+* `$OBJECT_list`: Returns a list of objects, optionally filtered by given parameters.
+
+For example, query `device(id:123)` to fetch a specific device (identified by its unique ID), and query `device_list` (with an optional set of fitlers) to fetch all devices.
+
+For more detail on constructing GraphQL queries, see the [Graphene documentation](https://docs.graphene-python.org/en/latest/).
+
+## Filtering
+
+The GraphQL API employs the same filtering logic as the UI and REST API. Filters can be specified as key-value pairs within parentheses immediately following the query name. For example, the following will return only sites within the North Carolina region with a status of active:
+
+```
+{"query": "query {sites(region:\"north-carolina\", status:\"active\") {name}}"}
+```
+
+## Authentication
+
+NetBox's GraphQL API uses the same API authentication tokens as its REST API. Authentication tokens are included with requests by attaching an `Authorization` HTTP header in the following form:
+
+```
+Authorization: Token $TOKEN
+```
+
+## Disabling the GraphQL API
+
+If not needed, the GraphQL API can be disabled by setting the [`GRAPHQL_ENABLED`](../configuration/optional-settings.md#graphql_enabled) configuration parameter to False and restarting NetBox.

+ 2 - 0
mkdocs.yml

@@ -87,6 +87,8 @@ nav:
         - Overview: 'rest-api/overview.md'
         - Overview: 'rest-api/overview.md'
         - Filtering: 'rest-api/filtering.md'
         - Filtering: 'rest-api/filtering.md'
         - Authentication: 'rest-api/authentication.md'
         - Authentication: 'rest-api/authentication.md'
+    - GraphQL API:
+        - Overview: 'graphql-api/overview.md'
     - Development:
     - Development:
         - Introduction: 'development/index.md'
         - Introduction: 'development/index.md'
         - Getting Started: 'development/getting-started.md'
         - Getting Started: 'development/getting-started.md'

+ 0 - 0
netbox/circuits/graphql/__init__.py


+ 21 - 0
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)
+    circuit_list = ObjectListField(CircuitType)
+
+    circuit_termination = ObjectField(CircuitTerminationType)
+    circuit_termination_list = ObjectListField(CircuitTerminationType)
+
+    circuit_type = ObjectField(CircuitTypeType)
+    circuit_type_list = ObjectListField(CircuitTypeType)
+
+    provider = ObjectField(ProviderType)
+    provider_list = ObjectListField(ProviderType)
+
+    provider_network = ObjectField(ProviderNetworkType)
+    provider_network_list = ObjectListField(ProviderNetworkType)

+ 50 - 0
netbox/circuits/graphql/types.py

@@ -0,0 +1,50 @@
+from circuits import filtersets, models
+from netbox.graphql.types import BaseObjectType, ObjectType, TaggedObjectType
+
+__all__ = (
+    'CircuitTerminationType',
+    'CircuitType',
+    'CircuitTypeType',
+    'ProviderType',
+    'ProviderNetworkType',
+)
+
+
+class CircuitTerminationType(BaseObjectType):
+
+    class Meta:
+        model = models.CircuitTermination
+        fields = '__all__'
+        filterset_class = filtersets.CircuitTerminationFilterSet
+
+
+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 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

+ 0 - 0
netbox/dcim/graphql/__init__.py


+ 105 - 0
netbox/dcim/graphql/schema.py

@@ -0,0 +1,105 @@
+import graphene
+
+from netbox.graphql.fields import ObjectField, ObjectListField
+from .types import *
+
+
+class DCIMQuery(graphene.ObjectType):
+    cable = ObjectField(CableType)
+    cable_list = ObjectListField(CableType)
+
+    console_port = ObjectField(ConsolePortType)
+    console_port_list = ObjectListField(ConsolePortType)
+
+    console_port_template = ObjectField(ConsolePortTemplateType)
+    console_port_template_list = ObjectListField(ConsolePortTemplateType)
+
+    console_server_port = ObjectField(ConsoleServerPortType)
+    console_server_port_list = ObjectListField(ConsoleServerPortType)
+
+    console_server_port_template = ObjectField(ConsoleServerPortTemplateType)
+    console_server_port_template_list = ObjectListField(ConsoleServerPortTemplateType)
+
+    device = ObjectField(DeviceType)
+    device_list = ObjectListField(DeviceType)
+
+    device_bay = ObjectField(DeviceBayType)
+    device_bay_list = ObjectListField(DeviceBayType)
+
+    device_bay_template = ObjectField(DeviceBayTemplateType)
+    device_bay_template_list = ObjectListField(DeviceBayTemplateType)
+
+    device_role = ObjectField(DeviceRoleType)
+    device_role_list = ObjectListField(DeviceRoleType)
+
+    device_type = ObjectField(DeviceTypeType)
+    device_type_list = ObjectListField(DeviceTypeType)
+
+    front_port = ObjectField(FrontPortType)
+    front_port_list = ObjectListField(FrontPortType)
+
+    front_port_template = ObjectField(FrontPortTemplateType)
+    front_port_template_list = ObjectListField(FrontPortTemplateType)
+
+    interface = ObjectField(InterfaceType)
+    interface_list = ObjectListField(InterfaceType)
+
+    interface_template = ObjectField(InterfaceTemplateType)
+    interface_template_list = ObjectListField(InterfaceTemplateType)
+
+    inventory_item = ObjectField(InventoryItemType)
+    inventory_item_list = ObjectListField(InventoryItemType)
+
+    location = ObjectField(LocationType)
+    location_list = ObjectListField(LocationType)
+
+    manufacturer = ObjectField(ManufacturerType)
+    manufacturer_list = ObjectListField(ManufacturerType)
+
+    platform = ObjectField(PlatformType)
+    platform_list = ObjectListField(PlatformType)
+
+    power_feed = ObjectField(PowerFeedType)
+    power_feed_list = ObjectListField(PowerFeedType)
+
+    power_outlet = ObjectField(PowerOutletType)
+    power_outlet_list = ObjectListField(PowerOutletType)
+
+    power_outlet_template = ObjectField(PowerOutletTemplateType)
+    power_outlet_template_list = ObjectListField(PowerOutletTemplateType)
+
+    power_panel = ObjectField(PowerPanelType)
+    power_panel_list = ObjectListField(PowerPanelType)
+
+    power_port = ObjectField(PowerPortType)
+    power_port_list = ObjectListField(PowerPortType)
+
+    power_port_template = ObjectField(PowerPortTemplateType)
+    power_port_template_list = ObjectListField(PowerPortTemplateType)
+
+    rack = ObjectField(RackType)
+    rack_list = ObjectListField(RackType)
+
+    rack_reservation = ObjectField(RackReservationType)
+    rack_reservation_list = ObjectListField(RackReservationType)
+
+    rack_role = ObjectField(RackRoleType)
+    rack_role_list = ObjectListField(RackRoleType)
+
+    rear_port = ObjectField(RearPortType)
+    rear_port_list = ObjectListField(RearPortType)
+
+    rear_port_template = ObjectField(RearPortTemplateType)
+    rear_port_template_list = ObjectListField(RearPortTemplateType)
+
+    region = ObjectField(RegionType)
+    region_list = ObjectListField(RegionType)
+
+    site = ObjectField(SiteType)
+    site_list = ObjectListField(SiteType)
+
+    site_group = ObjectField(SiteGroupType)
+    site_group_list = ObjectListField(SiteGroupType)
+
+    virtual_chassis = ObjectField(VirtualChassisType)
+    virtual_chassis_list = ObjectListField(VirtualChassisType)

+ 353 - 0
netbox/dcim/graphql/types.py

@@ -0,0 +1,353 @@
+from dcim import filtersets, models
+from netbox.graphql.types import BaseObjectType, ObjectType, TaggedObjectType
+
+__all__ = (
+    'CableType',
+    'ConsolePortType',
+    'ConsolePortTemplateType',
+    'ConsoleServerPortType',
+    'ConsoleServerPortTemplateType',
+    'DeviceType',
+    'DeviceBayType',
+    'DeviceBayTemplateType',
+    'DeviceRoleType',
+    'DeviceTypeType',
+    'FrontPortType',
+    'FrontPortTemplateType',
+    'InterfaceType',
+    'InterfaceTemplateType',
+    'InventoryItemType',
+    'LocationType',
+    'ManufacturerType',
+    'PlatformType',
+    'PowerFeedType',
+    'PowerOutletType',
+    'PowerOutletTemplateType',
+    'PowerPanelType',
+    'PowerPortType',
+    'PowerPortTemplateType',
+    'RackType',
+    'RackReservationType',
+    'RackRoleType',
+    'RearPortType',
+    'RearPortTemplateType',
+    'RegionType',
+    'SiteType',
+    'SiteGroupType',
+    'VirtualChassisType',
+)
+
+
+class CableType(TaggedObjectType):
+
+    class Meta:
+        model = models.Cable
+        fields = '__all__'
+        filterset_class = filtersets.CableFilterSet
+
+    def resolve_type(self, info):
+        return self.type or None
+
+    def resolve_length_unit(self, info):
+        return self.length_unit or None
+
+
+class ConsolePortType(TaggedObjectType):
+
+    class Meta:
+        model = models.ConsolePort
+        exclude = ('_path',)
+        filterset_class = filtersets.ConsolePortFilterSet
+
+    def resolve_type(self, info):
+        return self.type or None
+
+
+class ConsolePortTemplateType(BaseObjectType):
+
+    class Meta:
+        model = models.ConsolePortTemplate
+        fields = '__all__'
+        filterset_class = filtersets.ConsolePortTemplateFilterSet
+
+    def resolve_type(self, info):
+        return self.type or None
+
+
+class ConsoleServerPortType(TaggedObjectType):
+
+    class Meta:
+        model = models.ConsoleServerPort
+        exclude = ('_path',)
+        filterset_class = filtersets.ConsoleServerPortFilterSet
+
+    def resolve_type(self, info):
+        return self.type or None
+
+
+class ConsoleServerPortTemplateType(BaseObjectType):
+
+    class Meta:
+        model = models.ConsoleServerPortTemplate
+        fields = '__all__'
+        filterset_class = filtersets.ConsoleServerPortTemplateFilterSet
+
+    def resolve_type(self, info):
+        return self.type or None
+
+
+class DeviceType(TaggedObjectType):
+
+    class Meta:
+        model = models.Device
+        fields = '__all__'
+        filterset_class = filtersets.DeviceFilterSet
+
+    def resolve_face(self, info):
+        return self.face or None
+
+
+class DeviceBayType(TaggedObjectType):
+
+    class Meta:
+        model = models.DeviceBay
+        fields = '__all__'
+        filterset_class = filtersets.DeviceBayFilterSet
+
+
+class DeviceBayTemplateType(BaseObjectType):
+
+    class Meta:
+        model = models.DeviceBayTemplate
+        fields = '__all__'
+        filterset_class = filtersets.DeviceBayTemplateFilterSet
+
+
+class DeviceRoleType(ObjectType):
+
+    class Meta:
+        model = models.DeviceRole
+        fields = '__all__'
+        filterset_class = filtersets.DeviceRoleFilterSet
+
+
+class DeviceTypeType(TaggedObjectType):
+
+    class Meta:
+        model = models.DeviceType
+        fields = '__all__'
+        filterset_class = filtersets.DeviceTypeFilterSet
+
+    def resolve_subdevice_role(self, info):
+        return self.subdevice_role or None
+
+
+class FrontPortType(TaggedObjectType):
+
+    class Meta:
+        model = models.FrontPort
+        fields = '__all__'
+        filterset_class = filtersets.FrontPortFilterSet
+
+
+class FrontPortTemplateType(BaseObjectType):
+
+    class Meta:
+        model = models.FrontPortTemplate
+        fields = '__all__'
+        filterset_class = filtersets.FrontPortTemplateFilterSet
+
+
+class InterfaceType(TaggedObjectType):
+
+    class Meta:
+        model = models.Interface
+        exclude = ('_path',)
+        filterset_class = filtersets.InterfaceFilterSet
+
+    def resolve_mode(self, info):
+        return self.mode or None
+
+
+class InterfaceTemplateType(BaseObjectType):
+
+    class Meta:
+        model = models.InterfaceTemplate
+        fields = '__all__'
+        filterset_class = filtersets.InterfaceTemplateFilterSet
+
+
+class InventoryItemType(TaggedObjectType):
+
+    class Meta:
+        model = models.InventoryItem
+        fields = '__all__'
+        filterset_class = filtersets.InventoryItemFilterSet
+
+
+class LocationType(ObjectType):
+
+    class Meta:
+        model = models.Location
+        fields = '__all__'
+        filterset_class = filtersets.LocationFilterSet
+
+
+class ManufacturerType(ObjectType):
+
+    class Meta:
+        model = models.Manufacturer
+        fields = '__all__'
+        filterset_class = filtersets.ManufacturerFilterSet
+
+
+class PlatformType(ObjectType):
+
+    class Meta:
+        model = models.Platform
+        fields = '__all__'
+        filterset_class = filtersets.PlatformFilterSet
+
+
+class PowerFeedType(TaggedObjectType):
+
+    class Meta:
+        model = models.PowerFeed
+        exclude = ('_path',)
+        filterset_class = filtersets.PowerFeedFilterSet
+
+
+class PowerOutletType(TaggedObjectType):
+
+    class Meta:
+        model = models.PowerOutlet
+        exclude = ('_path',)
+        filterset_class = filtersets.PowerOutletFilterSet
+
+    def resolve_feed_leg(self, info):
+        return self.feed_leg or None
+
+    def resolve_type(self, info):
+        return self.type or None
+
+
+class PowerOutletTemplateType(BaseObjectType):
+
+    class Meta:
+        model = models.PowerOutletTemplate
+        fields = '__all__'
+        filterset_class = filtersets.PowerOutletTemplateFilterSet
+
+    def resolve_feed_leg(self, info):
+        return self.feed_leg or None
+
+    def resolve_type(self, info):
+        return self.type or None
+
+
+class PowerPanelType(TaggedObjectType):
+
+    class Meta:
+        model = models.PowerPanel
+        fields = '__all__'
+        filterset_class = filtersets.PowerPanelFilterSet
+
+
+class PowerPortType(TaggedObjectType):
+
+    class Meta:
+        model = models.PowerPort
+        exclude = ('_path',)
+        filterset_class = filtersets.PowerPortFilterSet
+
+    def resolve_type(self, info):
+        return self.type or None
+
+
+class PowerPortTemplateType(BaseObjectType):
+
+    class Meta:
+        model = models.PowerPortTemplate
+        fields = '__all__'
+        filterset_class = filtersets.PowerPortTemplateFilterSet
+
+    def resolve_type(self, info):
+        return self.type or None
+
+
+class RackType(TaggedObjectType):
+
+    class Meta:
+        model = models.Rack
+        fields = '__all__'
+        filterset_class = filtersets.RackFilterSet
+
+    def resolve_type(self, info):
+        return self.type or None
+
+    def resolve_outer_unit(self, info):
+        return self.outer_unit or None
+
+
+class RackReservationType(TaggedObjectType):
+
+    class Meta:
+        model = models.RackReservation
+        fields = '__all__'
+        filterset_class = filtersets.RackReservationFilterSet
+
+
+class RackRoleType(ObjectType):
+
+    class Meta:
+        model = models.RackRole
+        fields = '__all__'
+        filterset_class = filtersets.RackRoleFilterSet
+
+
+class RearPortType(TaggedObjectType):
+
+    class Meta:
+        model = models.RearPort
+        fields = '__all__'
+        filterset_class = filtersets.RearPortFilterSet
+
+
+class RearPortTemplateType(BaseObjectType):
+
+    class Meta:
+        model = models.RearPortTemplate
+        fields = '__all__'
+        filterset_class = filtersets.RearPortTemplateFilterSet
+
+
+class RegionType(ObjectType):
+
+    class Meta:
+        model = models.Region
+        fields = '__all__'
+        filterset_class = filtersets.RegionFilterSet
+
+
+class SiteType(TaggedObjectType):
+
+    class Meta:
+        model = models.Site
+        fields = '__all__'
+        filterset_class = filtersets.SiteFilterSet
+
+
+class SiteGroupType(ObjectType):
+
+    class Meta:
+        model = models.SiteGroup
+        fields = '__all__'
+        filterset_class = filtersets.SiteGroupFilterSet
+
+
+class VirtualChassisType(TaggedObjectType):
+
+    class Meta:
+        model = models.VirtualChassis
+        fields = '__all__'
+        filterset_class = filtersets.VirtualChassisFilterSet

+ 1 - 1
netbox/dcim/models/__init__.py

@@ -25,6 +25,7 @@ __all__ = (
     'Interface',
     'Interface',
     'InterfaceTemplate',
     'InterfaceTemplate',
     'InventoryItem',
     'InventoryItem',
+    'Location',
     'Manufacturer',
     'Manufacturer',
     'Platform',
     'Platform',
     'PowerFeed',
     'PowerFeed',
@@ -34,7 +35,6 @@ __all__ = (
     'PowerPort',
     'PowerPort',
     'PowerPortTemplate',
     'PowerPortTemplate',
     'Rack',
     'Rack',
-    'Location',
     'RackReservation',
     'RackReservation',
     'RackRole',
     'RackRole',
     'RearPort',
     'RearPort',

+ 0 - 0
netbox/extras/graphql/__init__.py


+ 30 - 0
netbox/extras/graphql/schema.py

@@ -0,0 +1,30 @@
+import graphene
+
+from netbox.graphql.fields import ObjectField, ObjectListField
+from .types import *
+
+
+class ExtrasQuery(graphene.ObjectType):
+    config_context = ObjectField(ConfigContextType)
+    config_context_list = ObjectListField(ConfigContextType)
+
+    custom_field = ObjectField(CustomFieldType)
+    custom_field_list = ObjectListField(CustomFieldType)
+
+    custom_link = ObjectField(CustomLinkType)
+    custom_link_list = ObjectListField(CustomLinkType)
+
+    export_template = ObjectField(ExportTemplateType)
+    export_template_list = ObjectListField(ExportTemplateType)
+
+    image_attachment = ObjectField(ImageAttachmentType)
+    image_attachment_list = ObjectListField(ImageAttachmentType)
+
+    journal_entry = ObjectField(JournalEntryType)
+    journal_entry_list = ObjectListField(JournalEntryType)
+
+    tag = ObjectField(TagType)
+    tag_list = ObjectListField(TagType)
+
+    webhook = ObjectField(WebhookType)
+    webhook_list = ObjectListField(WebhookType)

+ 77 - 0
netbox/extras/graphql/types.py

@@ -0,0 +1,77 @@
+from extras import filtersets, models
+from netbox.graphql.types import BaseObjectType
+
+__all__ = (
+    'ConfigContextType',
+    'CustomFieldType',
+    'CustomLinkType',
+    'ExportTemplateType',
+    'ImageAttachmentType',
+    'JournalEntryType',
+    'TagType',
+    'WebhookType',
+)
+
+
+class ConfigContextType(BaseObjectType):
+
+    class Meta:
+        model = models.ConfigContext
+        fields = '__all__'
+        filterset_class = filtersets.ConfigContextFilterSet
+
+
+class CustomFieldType(BaseObjectType):
+
+    class Meta:
+        model = models.CustomField
+        fields = '__all__'
+        filterset_class = filtersets.CustomFieldFilterSet
+
+
+class CustomLinkType(BaseObjectType):
+
+    class Meta:
+        model = models.CustomLink
+        fields = '__all__'
+        filterset_class = filtersets.CustomLinkFilterSet
+
+
+class ExportTemplateType(BaseObjectType):
+
+    class Meta:
+        model = models.ExportTemplate
+        fields = '__all__'
+        filterset_class = filtersets.ExportTemplateFilterSet
+
+
+class ImageAttachmentType(BaseObjectType):
+
+    class Meta:
+        model = models.ImageAttachment
+        fields = '__all__'
+        filterset_class = filtersets.ImageAttachmentFilterSet
+
+
+class JournalEntryType(BaseObjectType):
+
+    class Meta:
+        model = models.JournalEntry
+        fields = '__all__'
+        filterset_class = filtersets.JournalEntryFilterSet
+
+
+class TagType(BaseObjectType):
+
+    class Meta:
+        model = models.Tag
+        exclude = ('extras_taggeditem_items',)
+        filterset_class = filtersets.TagFilterSet
+
+
+class WebhookType(BaseObjectType):
+
+    class Meta:
+        model = models.Webhook
+        fields = '__all__'
+        filterset_class = filtersets.WebhookFilterSet

+ 2 - 1
netbox/extras/tests/test_api.py

@@ -270,7 +270,8 @@ class TagTest(APIViewTestCases.APIViewTestCase):
 class ImageAttachmentTest(
 class ImageAttachmentTest(
     APIViewTestCases.GetObjectViewTestCase,
     APIViewTestCases.GetObjectViewTestCase,
     APIViewTestCases.ListObjectsViewTestCase,
     APIViewTestCases.ListObjectsViewTestCase,
-    APIViewTestCases.DeleteObjectViewTestCase
+    APIViewTestCases.DeleteObjectViewTestCase,
+    APIViewTestCases.GraphQLTestCase
 ):
 ):
     model = ImageAttachment
     model = ImageAttachment
     brief_fields = ['display', 'id', 'image', 'name', 'url']
     brief_fields = ['display', 'id', 'image', 'name', 'url']

+ 0 - 0
netbox/ipam/graphql/__init__.py


+ 36 - 0
netbox/ipam/graphql/schema.py

@@ -0,0 +1,36 @@
+import graphene
+
+from netbox.graphql.fields import ObjectField, ObjectListField
+from .types import *
+
+
+class IPAMQuery(graphene.ObjectType):
+    aggregate = ObjectField(AggregateType)
+    aggregate_list = ObjectListField(AggregateType)
+
+    ip_address = ObjectField(IPAddressType)
+    ip_address_list = ObjectListField(IPAddressType)
+
+    prefix = ObjectField(PrefixType)
+    prefix_list = ObjectListField(PrefixType)
+
+    rir = ObjectField(RIRType)
+    rir_list = ObjectListField(RIRType)
+
+    role = ObjectField(RoleType)
+    role_list = ObjectListField(RoleType)
+
+    route_target = ObjectField(RouteTargetType)
+    route_target_list = ObjectListField(RouteTargetType)
+
+    service = ObjectField(ServiceType)
+    service_list = ObjectListField(ServiceType)
+
+    vlan = ObjectField(VLANType)
+    vlan_list = ObjectListField(VLANType)
+
+    vlan_group = ObjectField(VLANGroupType)
+    vlan_group_list = ObjectListField(VLANGroupType)
+
+    vrf = ObjectField(VRFType)
+    vrf_list = ObjectListField(VRFType)

+ 98 - 0
netbox/ipam/graphql/types.py

@@ -0,0 +1,98 @@
+from ipam import filtersets, models
+from netbox.graphql.types import ObjectType, TaggedObjectType
+
+__all__ = (
+    'AggregateType',
+    'IPAddressType',
+    'PrefixType',
+    'RIRType',
+    'RoleType',
+    'RouteTargetType',
+    'ServiceType',
+    'VLANType',
+    'VLANGroupType',
+    'VRFType',
+)
+
+
+class AggregateType(TaggedObjectType):
+
+    class Meta:
+        model = models.Aggregate
+        fields = '__all__'
+        filterset_class = filtersets.AggregateFilterSet
+
+
+class IPAddressType(TaggedObjectType):
+
+    class Meta:
+        model = models.IPAddress
+        fields = '__all__'
+        filterset_class = filtersets.IPAddressFilterSet
+
+    def resolve_role(self, info):
+        return self.role or None
+
+
+class PrefixType(TaggedObjectType):
+
+    class Meta:
+        model = models.Prefix
+        fields = '__all__'
+        filterset_class = filtersets.PrefixFilterSet
+
+
+class RIRType(ObjectType):
+
+    class Meta:
+        model = models.RIR
+        fields = '__all__'
+        filterset_class = filtersets.RIRFilterSet
+
+
+class RoleType(ObjectType):
+
+    class Meta:
+        model = models.Role
+        fields = '__all__'
+        filterset_class = filtersets.RoleFilterSet
+
+
+class RouteTargetType(TaggedObjectType):
+
+    class Meta:
+        model = models.RouteTarget
+        fields = '__all__'
+        filterset_class = filtersets.RouteTargetFilterSet
+
+
+class ServiceType(TaggedObjectType):
+
+    class Meta:
+        model = models.Service
+        fields = '__all__'
+        filterset_class = filtersets.ServiceFilterSet
+
+
+class VLANType(TaggedObjectType):
+
+    class Meta:
+        model = models.VLAN
+        fields = '__all__'
+        filterset_class = filtersets.VLANFilterSet
+
+
+class VLANGroupType(ObjectType):
+
+    class Meta:
+        model = models.VLANGroup
+        fields = '__all__'
+        filterset_class = filtersets.VLANGroupFilterSet
+
+
+class VRFType(TaggedObjectType):
+
+    class Meta:
+        model = models.VRF
+        fields = '__all__'
+        filterset_class = filtersets.VRFFilterSet

+ 4 - 0
netbox/netbox/api/exceptions.py

@@ -8,3 +8,7 @@ class ServiceUnavailable(APIException):
 
 
 class SerializerNotFound(Exception):
 class SerializerNotFound(Exception):
     pass
     pass
+
+
+class GraphQLTypeNotFound(Exception):
+    pass

+ 3 - 0
netbox/netbox/configuration.example.py

@@ -149,6 +149,9 @@ EXEMPT_VIEW_PERMISSIONS = [
     # 'ipam.prefix',
     # 'ipam.prefix',
 ]
 ]
 
 
+# Enable the GraphQL API
+GRAPHQL_ENABLED = True
+
 # HTTP proxies NetBox should use when sending outbound HTTP requests (e.g. for webhooks).
 # HTTP proxies NetBox should use when sending outbound HTTP requests (e.g. for webhooks).
 # HTTP_PROXIES = {
 # HTTP_PROXIES = {
 #     'http': 'http://10.10.1.10:3128',
 #     'http': 'http://10.10.1.10:3128',

+ 22 - 0
netbox/netbox/graphql/__init__.py

@@ -0,0 +1,22 @@
+import graphene
+from graphene_django.converter import convert_django_field
+from taggit.managers import TaggableManager
+
+from dcim.fields import MACAddressField
+from ipam.fields import IPAddressField, IPNetworkField
+
+
+@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)
+
+
+@convert_django_field.register(IPAddressField)
+@convert_django_field.register(IPNetworkField)
+@convert_django_field.register(MACAddressField)
+def convert_field_to_string(field, registry=None):
+    # TODO: Update to use get_django_field_description under django_graphene v3.0
+    return graphene.String(description=field.help_text, required=not field.null)

+ 65 - 0
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

+ 25 - 0
netbox/netbox/graphql/schema.py

@@ -0,0 +1,25 @@
+import graphene
+
+from circuits.graphql.schema import CircuitsQuery
+from dcim.graphql.schema import DCIMQuery
+from extras.graphql.schema import ExtrasQuery
+from ipam.graphql.schema import IPAMQuery
+from tenancy.graphql.schema import TenancyQuery
+from users.graphql.schema import UsersQuery
+from virtualization.graphql.schema import VirtualizationQuery
+
+
+class Query(
+    CircuitsQuery,
+    DCIMQuery,
+    ExtrasQuery,
+    IPAMQuery,
+    TenancyQuery,
+    UsersQuery,
+    VirtualizationQuery,
+    graphene.ObjectType
+):
+    pass
+
+
+schema = graphene.Schema(query=Query, auto_camelcase=False)

+ 64 - 0
netbox/netbox/graphql/types.py

@@ -0,0 +1,64 @@
+import graphene
+from django.contrib.contenttypes.models import ContentType
+from graphene.types.generic import GenericScalar
+from graphene_django import DjangoObjectType
+
+__all__ = (
+    'BaseObjectType',
+    'ObjectType',
+    'TaggedObjectType',
+)
+
+
+#
+# Base types
+#
+
+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):
+    """
+    Extends BaseObjectType with support for custom field data.
+    """
+    custom_fields = GenericScalar()
+
+    class Meta:
+        abstract = True
+
+    def resolve_custom_fields(self, info):
+        return self.custom_field_data
+
+
+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()
+
+
+#
+# Miscellaneous types
+#
+
+class ContentTypeType(DjangoObjectType):
+
+    class Meta:
+        model = ContentType
+        fields = ('id', 'app_label', 'model')

+ 25 - 0
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

+ 40 - 0
netbox/netbox/graphql/views.py

@@ -0,0 +1,40 @@
+from django.conf import settings
+from django.contrib.auth.views import redirect_to_login
+from django.http import HttpResponseNotFound, HttpResponseForbidden
+from django.urls import reverse
+from graphene_django.views import GraphQLView as GraphQLView_
+from rest_framework.exceptions import AuthenticationFailed
+
+from netbox.api.authentication import TokenAuthentication
+
+
+class GraphQLView(GraphQLView_):
+    """
+    Extends graphene_django's GraphQLView to support DRF's token-based authentication.
+    """
+    def dispatch(self, request, *args, **kwargs):
+
+        # Enforce GRAPHQL_ENABLED
+        if not settings.GRAPHQL_ENABLED:
+            return HttpResponseNotFound("The GraphQL API is not enabled.")
+
+        # Attempt to authenticate the user using a DRF token, if provided
+        if not request.user.is_authenticated:
+            authenticator = TokenAuthentication()
+            try:
+                auth_info = authenticator.authenticate(request)
+                if auth_info is not None:
+                    request.user = auth_info[0]  # User object
+            except AuthenticationFailed as exc:
+                return HttpResponseForbidden(exc.detail)
+
+        # Enforce LOGIN_REQUIRED
+        if settings.LOGIN_REQUIRED and not request.user.is_authenticated:
+
+            # If this is a human user, send a redirect to the login page
+            if self.request_wants_html(request):
+                return redirect_to_login(reverse('graphql'))
+
+            return HttpResponseForbidden("No credentials provided.")
+
+        return super().dispatch(request, *args, **kwargs)

+ 2 - 1
netbox/netbox/middleware.py

@@ -24,7 +24,8 @@ class LoginRequiredMiddleware(object):
         if settings.LOGIN_REQUIRED and not request.user.is_authenticated:
         if settings.LOGIN_REQUIRED and not request.user.is_authenticated:
             # Determine exempt paths
             # Determine exempt paths
             exempt_paths = [
             exempt_paths = [
-                reverse('api-root')
+                reverse('api-root'),
+                reverse('graphql'),
             ]
             ]
             if settings.METRICS_ENABLED:
             if settings.METRICS_ENABLED:
                 exempt_paths.append(reverse('prometheus-django-metrics'))
                 exempt_paths.append(reverse('prometheus-django-metrics'))

+ 15 - 1
netbox/netbox/settings.py

@@ -83,6 +83,7 @@ DOCS_ROOT = getattr(configuration, 'DOCS_ROOT', os.path.join(os.path.dirname(BAS
 EMAIL = getattr(configuration, 'EMAIL', {})
 EMAIL = getattr(configuration, 'EMAIL', {})
 ENFORCE_GLOBAL_UNIQUE = getattr(configuration, 'ENFORCE_GLOBAL_UNIQUE', False)
 ENFORCE_GLOBAL_UNIQUE = getattr(configuration, 'ENFORCE_GLOBAL_UNIQUE', False)
 EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', [])
 EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', [])
+GRAPHQL_ENABLED = getattr(configuration, 'GRAPHQL_ENABLED', True)
 HTTP_PROXIES = getattr(configuration, 'HTTP_PROXIES', None)
 HTTP_PROXIES = getattr(configuration, 'HTTP_PROXIES', None)
 INTERNAL_IPS = getattr(configuration, 'INTERNAL_IPS', ('127.0.0.1', '::1'))
 INTERNAL_IPS = getattr(configuration, 'INTERNAL_IPS', ('127.0.0.1', '::1'))
 LOGGING = getattr(configuration, 'LOGGING', {})
 LOGGING = getattr(configuration, 'LOGGING', {})
@@ -282,9 +283,11 @@ INSTALLED_APPS = [
     'cacheops',
     'cacheops',
     'corsheaders',
     'corsheaders',
     'debug_toolbar',
     'debug_toolbar',
+    'graphiql_debug_toolbar',
     'django_filters',
     'django_filters',
     'django_tables2',
     'django_tables2',
     'django_prometheus',
     'django_prometheus',
+    'graphene_django',
     'mptt',
     'mptt',
     'rest_framework',
     'rest_framework',
     'taggit',
     'taggit',
@@ -303,7 +306,7 @@ INSTALLED_APPS = [
 
 
 # Middleware
 # Middleware
 MIDDLEWARE = [
 MIDDLEWARE = [
-    'debug_toolbar.middleware.DebugToolbarMiddleware',
+    'graphiql_debug_toolbar.middleware.DebugToolbarMiddleware',
     'django_prometheus.middleware.PrometheusBeforeMiddleware',
     'django_prometheus.middleware.PrometheusBeforeMiddleware',
     'corsheaders.middleware.CorsMiddleware',
     'corsheaders.middleware.CorsMiddleware',
     'django.contrib.sessions.middleware.SessionMiddleware',
     'django.contrib.sessions.middleware.SessionMiddleware',
@@ -494,6 +497,17 @@ REST_FRAMEWORK = {
 }
 }
 
 
 
 
+#
+# Graphene
+#
+
+GRAPHENE = {
+    # Avoids naming collision on models with 'type' field; see
+    # https://github.com/graphql-python/graphene-django/issues/185
+    'DJANGO_CHOICE_FIELD_ENUM_V3_NAMING': True,
+}
+
+
 #
 #
 # drf_yasg (OpenAPI/Swagger)
 # drf_yasg (OpenAPI/Swagger)
 #
 #

+ 36 - 0
netbox/netbox/tests/test_graphql.py

@@ -0,0 +1,36 @@
+from django.test import override_settings
+from django.urls import reverse
+
+from utilities.testing import disable_warnings, TestCase
+
+
+class GraphQLTestCase(TestCase):
+
+    @override_settings(GRAPHQL_ENABLED=False)
+    def test_graphql_enabled(self):
+        """
+        The /graphql URL should return a 404 when GRAPHQL_ENABLED=False
+        """
+        url = reverse('graphql')
+        response = self.client.get(url)
+        self.assertHttpStatus(response, 404)
+
+    @override_settings(LOGIN_REQUIRED=True)
+    def test_graphiql_interface(self):
+        """
+        Test rendering of the GraphiQL interactive web interface
+        """
+        url = reverse('graphql')
+        header = {
+            'HTTP_ACCEPT': 'text/html',
+        }
+
+        # Authenticated request
+        response = self.client.get(url, **header)
+        self.assertHttpStatus(response, 200)
+
+        # Non-authenticated request
+        self.client.logout()
+        response = self.client.get(url, **header)
+        with disable_warnings('django.request'):
+            self.assertHttpStatus(response, 302)  # Redirect to login page

+ 5 - 0
netbox/netbox/urls.py

@@ -7,6 +7,8 @@ from drf_yasg.views import get_schema_view
 
 
 from extras.plugins.urls import plugin_admin_patterns, plugin_patterns, plugin_api_patterns
 from extras.plugins.urls import plugin_admin_patterns, plugin_patterns, plugin_api_patterns
 from netbox.api.views import APIRootView, StatusView
 from netbox.api.views import APIRootView, StatusView
+from netbox.graphql.schema import schema
+from netbox.graphql.views import GraphQLView
 from netbox.views import HomeView, StaticMediaFailureView, SearchView
 from netbox.views import HomeView, StaticMediaFailureView, SearchView
 from users.views import LoginView, LogoutView
 from users.views import LoginView, LogoutView
 from .admin import admin_site
 from .admin import admin_site
@@ -60,6 +62,9 @@ _patterns = [
     path('api/redoc/', schema_view.with_ui('redoc'), name='api_redocs'),
     path('api/redoc/', schema_view.with_ui('redoc'), name='api_redocs'),
     re_path(r'^api/swagger(?P<format>.json|.yaml)$', schema_view.without_ui(), name='schema_swagger'),
     re_path(r'^api/swagger(?P<format>.json|.yaml)$', schema_view.without_ui(), name='schema_swagger'),
 
 
+    # GraphQL
+    path('graphql/', GraphQLView.as_view(graphiql=True, schema=schema), name='graphql'),
+
     # Serving static media in Django to pipe it through LoginRequiredMiddleware
     # Serving static media in Django to pipe it through LoginRequiredMiddleware
     path('media/<path:path>', serve, {'document_root': settings.MEDIA_ROOT}),
     path('media/<path:path>', serve, {'document_root': settings.MEDIA_ROOT}),
     path('media-failure/', StaticMediaFailureView.as_view(), name='media_failure'),
     path('media-failure/', StaticMediaFailureView.as_view(), name='media_failure'),

+ 1 - 0
netbox/project-static/volt

@@ -0,0 +1 @@
+Subproject commit 942aa8c7bd506fb88b7c669cab173bc319eca309

+ 0 - 0
netbox/tenancy/graphql/__init__.py


+ 12 - 0
netbox/tenancy/graphql/schema.py

@@ -0,0 +1,12 @@
+import graphene
+
+from netbox.graphql.fields import ObjectField, ObjectListField
+from .types import *
+
+
+class TenancyQuery(graphene.ObjectType):
+    tenant = ObjectField(TenantType)
+    tenant_list = ObjectListField(TenantType)
+
+    tenant_group = ObjectField(TenantGroupType)
+    tenant_group_list = ObjectListField(TenantGroupType)

+ 23 - 0
netbox/tenancy/graphql/types.py

@@ -0,0 +1,23 @@
+from tenancy import filtersets, models
+from netbox.graphql.types import ObjectType, TaggedObjectType
+
+__all__ = (
+    'TenantType',
+    'TenantGroupType',
+)
+
+
+class TenantType(TaggedObjectType):
+
+    class Meta:
+        model = models.Tenant
+        fields = '__all__'
+        filterset_class = filtersets.TenantFilterSet
+
+
+class TenantGroupType(ObjectType):
+
+    class Meta:
+        model = models.TenantGroup
+        fields = '__all__'
+        filterset_class = filtersets.TenantGroupFilterSet

+ 0 - 0
netbox/users/graphql/__init__.py


+ 12 - 0
netbox/users/graphql/schema.py

@@ -0,0 +1,12 @@
+import graphene
+
+from netbox.graphql.fields import ObjectField, ObjectListField
+from .types import *
+
+
+class UsersQuery(graphene.ObjectType):
+    group = ObjectField(GroupType)
+    group_list = ObjectListField(GroupType)
+
+    user = ObjectField(UserType)
+    user_list = ObjectListField(UserType)

+ 37 - 0
netbox/users/graphql/types.py

@@ -0,0 +1,37 @@
+from django.contrib.auth.models import Group, User
+from graphene_django import DjangoObjectType
+
+from users import filtersets
+from utilities.querysets import RestrictedQuerySet
+
+__all__ = (
+    'GroupType',
+    'UserType',
+)
+
+
+class GroupType(DjangoObjectType):
+
+    class Meta:
+        model = Group
+        fields = ('id', 'name')
+        filterset_class = filtersets.GroupFilterSet
+
+    @classmethod
+    def get_queryset(cls, queryset, info):
+        return RestrictedQuerySet(model=Group)
+
+
+class UserType(DjangoObjectType):
+
+    class Meta:
+        model = User
+        fields = (
+            'id', 'username', 'password', 'first_name', 'last_name', 'email', 'is_staff', 'is_active', 'date_joined',
+            'groups',
+        )
+        filterset_class = filtersets.UserFilterSet
+
+    @classmethod
+    def get_queryset(cls, queryset, info):
+        return RestrictedQuerySet(model=User)

+ 16 - 2
netbox/users/tests/test_api.py

@@ -75,7 +75,14 @@ class GroupTest(APIViewTestCases.APIViewTestCase):
         Group.objects.bulk_create(users)
         Group.objects.bulk_create(users)
 
 
 
 
-class TokenTest(APIViewTestCases.APIViewTestCase):
+class TokenTest(
+    # No GraphQL support for Token
+    APIViewTestCases.GetObjectViewTestCase,
+    APIViewTestCases.ListObjectsViewTestCase,
+    APIViewTestCases.CreateObjectViewTestCase,
+    APIViewTestCases.UpdateObjectViewTestCase,
+    APIViewTestCases.DeleteObjectViewTestCase
+):
     model = Token
     model = Token
     brief_fields = ['display', 'id', 'key', 'url', 'write_enabled']
     brief_fields = ['display', 'id', 'key', 'url', 'write_enabled']
     bulk_update_data = {
     bulk_update_data = {
@@ -138,7 +145,14 @@ class TokenTest(APIViewTestCases.APIViewTestCase):
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
 
 
 
 
-class ObjectPermissionTest(APIViewTestCases.APIViewTestCase):
+class ObjectPermissionTest(
+    # No GraphQL support for ObjectPermission
+    APIViewTestCases.GetObjectViewTestCase,
+    APIViewTestCases.ListObjectsViewTestCase,
+    APIViewTestCases.CreateObjectViewTestCase,
+    APIViewTestCases.UpdateObjectViewTestCase,
+    APIViewTestCases.DeleteObjectViewTestCase
+):
     model = ObjectPermission
     model = ObjectPermission
     brief_fields = ['actions', 'display', 'enabled', 'groups', 'id', 'name', 'object_types', 'url', 'users']
     brief_fields = ['actions', 'display', 'enabled', 'groups', 'id', 'name', 'object_types', 'url', 'users']
 
 

+ 17 - 2
netbox/utilities/api.py

@@ -7,7 +7,7 @@ from django.urls import reverse
 from rest_framework import status
 from rest_framework import status
 from rest_framework.utils import formatting
 from rest_framework.utils import formatting
 
 
-from netbox.api.exceptions import SerializerNotFound
+from netbox.api.exceptions import GraphQLTypeNotFound, SerializerNotFound
 from .utils import dynamic_import
 from .utils import dynamic_import
 
 
 
 
@@ -24,10 +24,25 @@ def get_serializer_for_model(model, prefix=''):
         return dynamic_import(serializer_name)
         return dynamic_import(serializer_name)
     except AttributeError:
     except AttributeError:
         raise SerializerNotFound(
         raise SerializerNotFound(
-            "Could not determine serializer for {}.{} with prefix '{}'".format(app_name, model_name, prefix)
+            f"Could not determine serializer for {app_name}.{model_name} with prefix '{prefix}'"
         )
         )
 
 
 
 
+def get_graphql_type_for_model(model):
+    """
+    Return the GraphQL type class for the given model.
+    """
+    app_name, model_name = model._meta.label.split('.')
+    # Object types for Django's auth models are in the users app
+    if app_name == 'auth':
+        app_name = 'users'
+    class_name = f'{app_name}.graphql.types.{model_name}Type'
+    try:
+        return dynamic_import(class_name)
+    except AttributeError:
+        raise GraphQLTypeNotFound(f"Could not find GraphQL type for {app_name}.{model_name}")
+
+
 def is_api_request(request):
 def is_api_request(request):
     """
     """
     Return True of the request is being made via the REST API.
     Return True of the request is being made via the REST API.

+ 94 - 2
netbox/utilities/testing/api.py

@@ -1,14 +1,18 @@
+import json
+
 from django.conf import settings
 from django.conf import settings
 from django.contrib.auth.models import User
 from django.contrib.auth.models import User
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.urls import reverse
 from django.urls import reverse
 from django.test import override_settings
 from django.test import override_settings
+from graphene.types.dynamic import Dynamic
 from rest_framework import status
 from rest_framework import status
 from rest_framework.test import APIClient
 from rest_framework.test import APIClient
 
 
 from extras.choices import ObjectChangeActionChoices
 from extras.choices import ObjectChangeActionChoices
 from extras.models import ObjectChange
 from extras.models import ObjectChange
 from users.models import ObjectPermission, Token
 from users.models import ObjectPermission, Token
+from utilities.api import get_graphql_type_for_model
 from .base import ModelTestCase
 from .base import ModelTestCase
 from .utils import disable_warnings
 from .utils import disable_warnings
 
 
@@ -20,7 +24,7 @@ __all__ = (
 
 
 
 
 #
 #
-# REST API Tests
+# REST/GraphQL API Tests
 #
 #
 
 
 class APITestCase(ModelTestCase):
 class APITestCase(ModelTestCase):
@@ -421,11 +425,99 @@ class APIViewTestCases:
             self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
             self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
             self.assertEqual(self._get_queryset().count(), initial_count - 3)
             self.assertEqual(self._get_queryset().count(), initial_count - 3)
 
 
+    class GraphQLTestCase(APITestCase):
+
+        def _get_graphql_base_name(self):
+            """
+            Return graphql_base_name, if set. Otherwise, construct the base name for the query
+            field from the model's verbose name.
+            """
+            base_name = self.model._meta.verbose_name.lower().replace(' ', '_')
+            return getattr(self, 'graphql_base_name', base_name)
+
+        def _build_query(self, name, **filters):
+            type_class = get_graphql_type_for_model(self.model)
+            if filters:
+                filter_string = ', '.join(f'{k}:{v}' for k, v in filters.items())
+                filter_string = f'({filter_string})'
+            else:
+                filter_string = ''
+
+            # Compile list of fields to include
+            fields_string = ''
+            for field_name, field in type_class._meta.fields.items():
+                if type(field) is Dynamic:
+                    # Dynamic fields must specify a subselection
+                    fields_string += f'{field_name} {{ id }}\n'
+                else:
+                    fields_string += f'{field_name}\n'
+
+            query = f"""
+            {{
+                {name}{filter_string} {{
+                    {fields_string}
+                }}
+            }}
+            """
+
+            return query
+
+        @override_settings(LOGIN_REQUIRED=True)
+        def test_graphql_get_object(self):
+            url = reverse('graphql')
+            field_name = self._get_graphql_base_name()
+            object_id = self._get_queryset().first().pk
+            query = self._build_query(field_name, id=object_id)
+
+            # Non-authenticated requests should fail
+            with disable_warnings('django.request'):
+                self.assertHttpStatus(self.client.post(url, data={'query': query}), status.HTTP_403_FORBIDDEN)
+
+            # Add object-level permission
+            obj_perm = ObjectPermission(
+                name='Test permission',
+                actions=['view']
+            )
+            obj_perm.save()
+            obj_perm.users.add(self.user)
+            obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
+
+            response = self.client.post(url, data={'query': query}, **self.header)
+            self.assertHttpStatus(response, status.HTTP_200_OK)
+            data = json.loads(response.content)
+            self.assertNotIn('errors', data)
+
+        @override_settings(LOGIN_REQUIRED=True)
+        def test_graphql_list_objects(self):
+            url = reverse('graphql')
+            field_name = f'{self._get_graphql_base_name()}_list'
+            query = self._build_query(field_name)
+
+            # Non-authenticated requests should fail
+            with disable_warnings('django.request'):
+                self.assertHttpStatus(self.client.post(url, data={'query': query}), status.HTTP_403_FORBIDDEN)
+
+            # Add object-level permission
+            obj_perm = ObjectPermission(
+                name='Test permission',
+                actions=['view']
+            )
+            obj_perm.save()
+            obj_perm.users.add(self.user)
+            obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
+
+            response = self.client.post(url, data={'query': query}, **self.header)
+            self.assertHttpStatus(response, status.HTTP_200_OK)
+            data = json.loads(response.content)
+            self.assertNotIn('errors', data)
+            self.assertGreater(len(data['data'][field_name]), 0)
+
     class APIViewTestCase(
     class APIViewTestCase(
         GetObjectViewTestCase,
         GetObjectViewTestCase,
         ListObjectsViewTestCase,
         ListObjectsViewTestCase,
         CreateObjectViewTestCase,
         CreateObjectViewTestCase,
         UpdateObjectViewTestCase,
         UpdateObjectViewTestCase,
-        DeleteObjectViewTestCase
+        DeleteObjectViewTestCase,
+        GraphQLTestCase
     ):
     ):
         pass
         pass

+ 0 - 0
netbox/virtualization/graphql/__init__.py


+ 21 - 0
netbox/virtualization/graphql/schema.py

@@ -0,0 +1,21 @@
+import graphene
+
+from netbox.graphql.fields import ObjectField, ObjectListField
+from .types import *
+
+
+class VirtualizationQuery(graphene.ObjectType):
+    cluster = ObjectField(ClusterType)
+    cluster_list = ObjectListField(ClusterType)
+
+    cluster_group = ObjectField(ClusterGroupType)
+    cluster_group_list = ObjectListField(ClusterGroupType)
+
+    cluster_type = ObjectField(ClusterTypeType)
+    cluster_type_list = ObjectListField(ClusterTypeType)
+
+    virtual_machine = ObjectField(VirtualMachineType)
+    virtual_machine_list = ObjectListField(VirtualMachineType)
+
+    vm_interface = ObjectField(VMInterfaceType)
+    vm_interface_list = ObjectListField(VMInterfaceType)

+ 53 - 0
netbox/virtualization/graphql/types.py

@@ -0,0 +1,53 @@
+from virtualization import filtersets, models
+from netbox.graphql.types import ObjectType, TaggedObjectType
+
+__all__ = (
+    'ClusterType',
+    'ClusterGroupType',
+    'ClusterTypeType',
+    'VirtualMachineType',
+    'VMInterfaceType',
+)
+
+
+class ClusterType(TaggedObjectType):
+
+    class Meta:
+        model = models.Cluster
+        fields = '__all__'
+        filterset_class = filtersets.ClusterFilterSet
+
+
+class ClusterGroupType(ObjectType):
+
+    class Meta:
+        model = models.ClusterGroup
+        fields = '__all__'
+        filterset_class = filtersets.ClusterGroupFilterSet
+
+
+class ClusterTypeType(ObjectType):
+
+    class Meta:
+        model = models.ClusterType
+        fields = '__all__'
+        filterset_class = filtersets.ClusterTypeFilterSet
+
+
+class VirtualMachineType(TaggedObjectType):
+
+    class Meta:
+        model = models.VirtualMachine
+        fields = '__all__'
+        filterset_class = filtersets.VirtualMachineFilterSet
+
+
+class VMInterfaceType(TaggedObjectType):
+
+    class Meta:
+        model = models.VMInterface
+        fields = '__all__'
+        filterset_class = filtersets.VMInterfaceFilterSet
+
+    def resolve_mode(self, info):
+        return self.mode or None

+ 1 - 0
netbox/virtualization/tests/test_api.py

@@ -211,6 +211,7 @@ class VMInterfaceTest(APIViewTestCases.APIViewTestCase):
     bulk_update_data = {
     bulk_update_data = {
         'description': 'New description',
         'description': 'New description',
     }
     }
+    graphql_base_name = 'vm_interface'
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):

+ 2 - 0
requirements.txt

@@ -3,6 +3,7 @@ django-cacheops==6.0
 django-cors-headers==3.7.0
 django-cors-headers==3.7.0
 django-debug-toolbar==3.2.1
 django-debug-toolbar==3.2.1
 django-filter==2.4.0
 django-filter==2.4.0
+django-graphiql-debug-toolbar==0.1.4
 django-mptt==0.12.0
 django-mptt==0.12.0
 django-pglocks==1.0.4
 django-pglocks==1.0.4
 django-prometheus==2.1.0
 django-prometheus==2.1.0
@@ -12,6 +13,7 @@ django-taggit==1.4.0
 django-timezone-field==4.1.2
 django-timezone-field==4.1.2
 djangorestframework==3.12.4
 djangorestframework==3.12.4
 drf-yasg[validation]==1.20.0
 drf-yasg[validation]==1.20.0
+graphene_django==2.15.0
 gunicorn==20.1.0
 gunicorn==20.1.0
 Jinja2==3.0.1
 Jinja2==3.0.1
 Markdown==3.3.4
 Markdown==3.3.4