diff --git a/docs/plugins/development/graphql.md b/docs/plugins/development/graphql.md new file mode 100644 index 000000000..372498516 --- /dev/null +++ b/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 diff --git a/docs/plugins/development/index.md b/docs/plugins/development/index.md index 07a04f39f..fa8dfa556 100644 --- a/docs/plugins/development/index.md +++ b/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. diff --git a/mkdocs.yml b/mkdocs.yml index 004f21c5e..bc582cb2f 100644 --- a/mkdocs.yml +++ b/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' diff --git a/netbox/extras/plugins/__init__.py b/netbox/extras/plugins/__init__.py index f297d43e1..cef537bd8 100644 --- a/netbox/extras/plugins/__init__.py +++ b/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 # diff --git a/netbox/extras/tests/dummy_plugin/graphql.py b/netbox/extras/tests/dummy_plugin/graphql.py new file mode 100644 index 000000000..27ecd9ce0 --- /dev/null +++ b/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 diff --git a/netbox/extras/tests/test_plugins.py b/netbox/extras/tests/test_plugins.py index 608fc58dc..299cab9ef 100644 --- a/netbox/extras/tests/test_plugins.py +++ b/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)) diff --git a/netbox/netbox/graphql/fields.py b/netbox/netbox/graphql/fields.py index e3ef39f4a..57685389e 100644 --- a/netbox/netbox/graphql/fields.py +++ b/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) diff --git a/netbox/netbox/graphql/schema.py b/netbox/netbox/graphql/schema.py index 812c1656d..f0bc8559c 100644 --- a/netbox/netbox/graphql/schema.py +++ b/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