jeremystretch 4 лет назад
Родитель
Сommit
03ea257711

+ 41 - 0
docs/plugins/development/graphql.md

@@ -0,0 +1,41 @@
+# GraphQL API
+
+## Defining the Schema Class
+
+A plugin can extend NetBox's GraphQL API by registering its own schema class. By default, NetBox will attempt to import `graphql.schema` from the plugin, if it exists. This path can be overridden by defining `graphql_schema` on the PluginConfig instance as the dotted path to the desired Python class. This class must be a subclass of `graphene.ObjectType`.
+
+### Example
+
+```python
+# graphql.py
+import graphene
+from netbox.graphql.fields import ObjectField, ObjectListField
+from . import filtersets, models
+
+class MyModelType(graphene.ObjectType):
+
+    class Meta:
+        model = models.MyModel
+        fields = '__all__'
+        filterset_class = filtersets.MyModelFilterSet
+
+class MyQuery(graphene.ObjectType):
+    mymodel = ObjectField(MyModelType)
+    mymodel_list = ObjectListField(MyModelType)
+
+schema = MyQuery
+```
+
+## GraphQL Fields
+
+::: netbox.graphql.fields.ObjectField
+    selection:
+      members: false
+    rendering:
+      show_source: false
+
+::: netbox.graphql.fields.ObjectListField
+    selection:
+      members: false
+    rendering:
+      show_source: false

+ 19 - 18
docs/plugins/development/index.md

@@ -22,7 +22,7 @@ However, keep in mind that each piece of functionality is entirely optional. For
 
 ### Plugin Structure
 
-Although the specific structure of a plugin is largely left to the discretion of its authors, a typical NetBox plugin looks something like this:
+Although the specific structure of a plugin is largely left to the discretion of its authors, a typical NetBox plugin might look something like this:
 
 ```no-highlight
 project-name/
@@ -102,23 +102,24 @@ NetBox looks for the `config` variable within a plugin's `__init__.py` to load i
 
 #### PluginConfig Attributes
 
-| Name | Description                                                                                                   |
-| ---- |---------------------------------------------------------------------------------------------------------------|
-| `name` | Raw plugin name; same as the plugin's source directory                                                        |
-| `verbose_name` | Human-friendly name for the plugin                                                                            |
-| `version` | Current release ([semantic versioning](https://semver.org/) is encouraged)                                    |
-| `description` | Brief description of the plugin's purpose                                                                     |
-| `author` | Name of plugin's author                                                                                       |
-| `author_email` | Author's public email address                                                                                 |
-| `base_url` | Base path to use for plugin URLs (optional). If not specified, the project's `name` will be used.             |
-| `required_settings` | A list of any configuration parameters that **must** be defined by the user                                   |
-| `default_settings` | A dictionary of configuration parameters and their default values                                             |
-| `min_version` | Minimum version of NetBox with which the plugin is compatible                                                 |
-| `max_version` | Maximum version of NetBox with which the plugin is compatible                                                 |
-| `middleware` | A list of middleware classes to append after NetBox's build-in middleware                                     |
-| `template_extensions` | The dotted path to the list of template extension classes (default: `template_content.template_extensions`)   |
-| `menu_items` | The dotted path to the list of menu items provided by the plugin (default: `navigation.menu_items`)           |
-| `user_preferences` | The dotted path to the dictionary mapping of user preferences defined by the plugin (default: `preferences.preferences`) |
+| Name                  | Description                                                                                                              |
+|-----------------------|--------------------------------------------------------------------------------------------------------------------------|
+| `name`                | Raw plugin name; same as the plugin's source directory                                                                   |
+| `verbose_name`        | Human-friendly name for the plugin                                                                                       |
+| `version`             | Current release ([semantic versioning](https://semver.org/) is encouraged)                                               |
+| `description`         | Brief description of the plugin's purpose                                                                                |
+| `author`              | Name of plugin's author                                                                                                  |
+| `author_email`        | Author's public email address                                                                                            |
+| `base_url`            | Base path to use for plugin URLs (optional). If not specified, the project's `name` will be used.                        |
+| `required_settings`   | A list of any configuration parameters that **must** be defined by the user                                              |
+| `default_settings`    | A dictionary of configuration parameters and their default values                                                        |
+| `min_version`         | Minimum version of NetBox with which the plugin is compatible                                                            |
+| `max_version`         | Maximum version of NetBox with which the plugin is compatible                                                            |
+| `middleware`          | A list of middleware classes to append after NetBox's build-in middleware                                                |
+| `template_extensions` | The dotted path to the list of template extension classes (default: `template_content.template_extensions`)              |
+| `menu_items`          | The dotted path to the list of menu items provided by the plugin (default: `navigation.menu_items`)                      |
+| `graphql_schema`      | The dotted path to the plugin's GraphQL schema class, if any (default: `graphql.schema`)                                 |
+| `user_preferences`    | The dotted path to the dictionary mapping of user preferences defined by the plugin (default: `preferences.preferences`) |
 
 All required settings must be configured by the user. If a configuration parameter is listed in both `required_settings` and `default_settings`, the default setting will be ignored.
 

+ 1 - 0
mkdocs.yml

@@ -108,6 +108,7 @@ nav:
             - Forms: 'plugins/development/forms.md'
             - Filter Sets: 'plugins/development/filtersets.md'
             - REST API: 'plugins/development/rest-api.md'
+            - GraphQL API: 'plugins/development/graphql.md'
             - Background Tasks: 'plugins/development/background-tasks.md'
     - Administration:
         - Authentication: 'administration/authentication.md'

+ 24 - 6
netbox/extras/plugins/__init__.py

@@ -14,9 +14,10 @@ from extras.plugins.utils import import_object
 
 # Initialize plugin registry
 registry['plugins'] = {
-    'template_extensions': collections.defaultdict(list),
+    'graphql_schemas': [],
     'menu_items': {},
     'preferences': {},
+    'template_extensions': collections.defaultdict(list),
 }
 
 
@@ -55,13 +56,15 @@ class PluginConfig(AppConfig):
 
     # Default integration paths. Plugin authors can override these to customize the paths to
     # integrated components.
-    template_extensions = 'template_content.template_extensions'
+    graphql_schema = 'graphql.schema'
     menu_items = 'navigation.menu_items'
+    template_extensions = 'template_content.template_extensions'
     user_preferences = 'preferences.preferences'
 
     def ready(self):
+        plugin_name = self.name.rsplit('.', 1)[1]
 
-        # Register template content
+        # Register template content (if defined)
         template_extensions = import_object(f"{self.__module__}.{self.template_extensions}")
         if template_extensions is not None:
             register_template_extensions(template_extensions)
@@ -71,10 +74,14 @@ class PluginConfig(AppConfig):
         if menu_items is not None:
             register_menu_items(self.verbose_name, menu_items)
 
-        # Register user preferences
+        # Register GraphQL schema (if defined)
+        graphql_schema = import_object(f"{self.__module__}.{self.graphql_schema}")
+        if graphql_schema is not None:
+            register_graphql_schema(graphql_schema)
+
+        # Register user preferences (if defined)
         user_preferences = import_object(f"{self.__module__}.{self.user_preferences}")
         if user_preferences is not None:
-            plugin_name = self.name.rsplit('.', 1)[1]
             register_user_preferences(plugin_name, user_preferences)
 
     @classmethod
@@ -180,7 +187,7 @@ def register_template_extensions(class_list):
     # Validation
     for template_extension in class_list:
         if not inspect.isclass(template_extension):
-            raise TypeError(f"PluginTemplateExtension class {template_extension} was passes as an instance!")
+            raise TypeError(f"PluginTemplateExtension class {template_extension} was passed as an instance!")
         if not issubclass(template_extension, PluginTemplateExtension):
             raise TypeError(f"{template_extension} is not a subclass of extras.plugins.PluginTemplateExtension!")
         if template_extension.model is None:
@@ -254,6 +261,17 @@ def register_menu_items(section_name, class_list):
     registry['plugins']['menu_items'][section_name] = class_list
 
 
+#
+# GraphQL schemas
+#
+
+def register_graphql_schema(graphql_schema):
+    """
+    Register a GraphQL schema class for inclusion in NetBox's GraphQL API.
+    """
+    registry['plugins']['graphql_schemas'].append(graphql_schema)
+
+
 #
 # User preferences
 #

+ 21 - 0
netbox/extras/tests/dummy_plugin/graphql.py

@@ -0,0 +1,21 @@
+import graphene
+from graphene_django import DjangoObjectType
+
+from netbox.graphql.fields import ObjectField, ObjectListField
+
+from . import models
+
+
+class DummyModelType(DjangoObjectType):
+
+    class Meta:
+        model = models.DummyModel
+        fields = '__all__'
+
+
+class DummyQuery(graphene.ObjectType):
+    dummymodel = ObjectField(DummyModelType)
+    dummymodel_list = ObjectListField(DummyModelType)
+
+
+schema = DummyQuery

+ 10 - 0
netbox/extras/tests/test_plugins.py

@@ -7,6 +7,7 @@ from django.urls import reverse
 
 from extras.registry import registry
 from extras.tests.dummy_plugin import config as dummy_config
+from netbox.graphql.schema import Query
 
 
 @skipIf('extras.tests.dummy_plugin' not in settings.PLUGINS, "dummy_plugin not in settings.PLUGINS")
@@ -143,3 +144,12 @@ class PluginTest(TestCase):
         user_config = {'bar': 456}
         DummyConfigWithDefaultSettings.validate(user_config, settings.VERSION)
         self.assertEqual(user_config['bar'], 456)
+
+    def test_graphql(self):
+        """
+        Validate the registration and operation of plugin-provided GraphQL schemas.
+        """
+        from extras.tests.dummy_plugin.graphql import DummyQuery
+
+        self.assertIn(DummyQuery, registry['plugins']['graphql_schemas'])
+        self.assertTrue(issubclass(Query, DummyQuery))

+ 6 - 7
netbox/netbox/graphql/fields.py

@@ -41,15 +41,14 @@ 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
+        filter_kwargs = {}
 
         # 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)
+        filterset_class = getattr(_type._meta, 'filterset_class', None)
+        if filterset_class:
+            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)
 

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

@@ -3,6 +3,7 @@ import graphene
 from circuits.graphql.schema import CircuitsQuery
 from dcim.graphql.schema import DCIMQuery
 from extras.graphql.schema import ExtrasQuery
+from extras.registry import registry
 from ipam.graphql.schema import IPAMQuery
 from tenancy.graphql.schema import TenancyQuery
 from users.graphql.schema import UsersQuery
@@ -19,6 +20,7 @@ class Query(
     UsersQuery,
     VirtualizationQuery,
     WirelessQuery,
+    *registry['plugins']['graphql_schemas'],  # Append plugin schemas
     graphene.ObjectType
 ):
     pass