mirror of
https://github.com/netbox-community/netbox.git
synced 2024-05-10 07:54:54 +00:00
Initial work on GraphQL
This commit is contained in:
41
docs/plugins/development/graphql.md
Normal file
41
docs/plugins/development/graphql.md
Normal file
@ -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
|
@ -22,7 +22,7 @@ However, keep in mind that each piece of functionality is entirely optional. For
|
|||||||
|
|
||||||
### Plugin Structure
|
### 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
|
```no-highlight
|
||||||
project-name/
|
project-name/
|
||||||
@ -102,23 +102,24 @@ NetBox looks for the `config` variable within a plugin's `__init__.py` to load i
|
|||||||
|
|
||||||
#### PluginConfig Attributes
|
#### PluginConfig Attributes
|
||||||
|
|
||||||
| Name | Description |
|
| Name | Description |
|
||||||
| ---- |---------------------------------------------------------------------------------------------------------------|
|
|-----------------------|--------------------------------------------------------------------------------------------------------------------------|
|
||||||
| `name` | Raw plugin name; same as the plugin's source directory |
|
| `name` | Raw plugin name; same as the plugin's source directory |
|
||||||
| `verbose_name` | Human-friendly name for the plugin |
|
| `verbose_name` | Human-friendly name for the plugin |
|
||||||
| `version` | Current release ([semantic versioning](https://semver.org/) is encouraged) |
|
| `version` | Current release ([semantic versioning](https://semver.org/) is encouraged) |
|
||||||
| `description` | Brief description of the plugin's purpose |
|
| `description` | Brief description of the plugin's purpose |
|
||||||
| `author` | Name of plugin's author |
|
| `author` | Name of plugin's author |
|
||||||
| `author_email` | Author's public email address |
|
| `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. |
|
| `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 |
|
| `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 |
|
| `default_settings` | A dictionary of configuration parameters and their default values |
|
||||||
| `min_version` | Minimum version of NetBox with which the plugin is compatible |
|
| `min_version` | Minimum version of NetBox with which the plugin is compatible |
|
||||||
| `max_version` | Maximum 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 |
|
| `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`) |
|
| `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`) |
|
| `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`) |
|
| `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.
|
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.
|
||||||
|
|
||||||
|
@ -108,6 +108,7 @@ nav:
|
|||||||
- Forms: 'plugins/development/forms.md'
|
- Forms: 'plugins/development/forms.md'
|
||||||
- Filter Sets: 'plugins/development/filtersets.md'
|
- Filter Sets: 'plugins/development/filtersets.md'
|
||||||
- REST API: 'plugins/development/rest-api.md'
|
- REST API: 'plugins/development/rest-api.md'
|
||||||
|
- GraphQL API: 'plugins/development/graphql.md'
|
||||||
- Background Tasks: 'plugins/development/background-tasks.md'
|
- Background Tasks: 'plugins/development/background-tasks.md'
|
||||||
- Administration:
|
- Administration:
|
||||||
- Authentication: 'administration/authentication.md'
|
- Authentication: 'administration/authentication.md'
|
||||||
|
@ -14,9 +14,10 @@ from extras.plugins.utils import import_object
|
|||||||
|
|
||||||
# Initialize plugin registry
|
# Initialize plugin registry
|
||||||
registry['plugins'] = {
|
registry['plugins'] = {
|
||||||
'template_extensions': collections.defaultdict(list),
|
'graphql_schemas': [],
|
||||||
'menu_items': {},
|
'menu_items': {},
|
||||||
'preferences': {},
|
'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
|
# Default integration paths. Plugin authors can override these to customize the paths to
|
||||||
# integrated components.
|
# integrated components.
|
||||||
template_extensions = 'template_content.template_extensions'
|
graphql_schema = 'graphql.schema'
|
||||||
menu_items = 'navigation.menu_items'
|
menu_items = 'navigation.menu_items'
|
||||||
|
template_extensions = 'template_content.template_extensions'
|
||||||
user_preferences = 'preferences.preferences'
|
user_preferences = 'preferences.preferences'
|
||||||
|
|
||||||
def ready(self):
|
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}")
|
template_extensions = import_object(f"{self.__module__}.{self.template_extensions}")
|
||||||
if template_extensions is not None:
|
if template_extensions is not None:
|
||||||
register_template_extensions(template_extensions)
|
register_template_extensions(template_extensions)
|
||||||
@ -71,10 +74,14 @@ class PluginConfig(AppConfig):
|
|||||||
if menu_items is not None:
|
if menu_items is not None:
|
||||||
register_menu_items(self.verbose_name, menu_items)
|
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}")
|
user_preferences = import_object(f"{self.__module__}.{self.user_preferences}")
|
||||||
if user_preferences is not None:
|
if user_preferences is not None:
|
||||||
plugin_name = self.name.rsplit('.', 1)[1]
|
|
||||||
register_user_preferences(plugin_name, user_preferences)
|
register_user_preferences(plugin_name, user_preferences)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -180,7 +187,7 @@ def register_template_extensions(class_list):
|
|||||||
# Validation
|
# Validation
|
||||||
for template_extension in class_list:
|
for template_extension in class_list:
|
||||||
if not inspect.isclass(template_extension):
|
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):
|
if not issubclass(template_extension, PluginTemplateExtension):
|
||||||
raise TypeError(f"{template_extension} is not a subclass of extras.plugins.PluginTemplateExtension!")
|
raise TypeError(f"{template_extension} is not a subclass of extras.plugins.PluginTemplateExtension!")
|
||||||
if template_extension.model is None:
|
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
|
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
|
# User preferences
|
||||||
#
|
#
|
||||||
|
21
netbox/extras/tests/dummy_plugin/graphql.py
Normal file
21
netbox/extras/tests/dummy_plugin/graphql.py
Normal file
@ -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
|
@ -7,6 +7,7 @@ from django.urls import reverse
|
|||||||
|
|
||||||
from extras.registry import registry
|
from extras.registry import registry
|
||||||
from extras.tests.dummy_plugin import config as dummy_config
|
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")
|
@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}
|
user_config = {'bar': 456}
|
||||||
DummyConfigWithDefaultSettings.validate(user_config, settings.VERSION)
|
DummyConfigWithDefaultSettings.validate(user_config, settings.VERSION)
|
||||||
self.assertEqual(user_config['bar'], 456)
|
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))
|
||||||
|
@ -41,15 +41,14 @@ class ObjectListField(DjangoListField):
|
|||||||
Retrieve a list of objects, optionally filtered by one or more FilterSet filters.
|
Retrieve a list of objects, optionally filtered by one or more FilterSet filters.
|
||||||
"""
|
"""
|
||||||
def __init__(self, _type, *args, **kwargs):
|
def __init__(self, _type, *args, **kwargs):
|
||||||
|
filter_kwargs = {}
|
||||||
assert hasattr(_type._meta, 'filterset_class'), "DjangoFilterListField must define filterset_class under Meta"
|
|
||||||
filterset_class = _type._meta.filterset_class
|
|
||||||
|
|
||||||
# Get FilterSet kwargs
|
# Get FilterSet kwargs
|
||||||
filter_kwargs = {}
|
filterset_class = getattr(_type._meta, 'filterset_class', None)
|
||||||
for filter_name, filter_field in filterset_class.get_filters().items():
|
if filterset_class:
|
||||||
field_type = get_graphene_type(type(filter_field))
|
for filter_name, filter_field in filterset_class.get_filters().items():
|
||||||
filter_kwargs[filter_name] = graphene.Argument(field_type)
|
field_type = get_graphene_type(type(filter_field))
|
||||||
|
filter_kwargs[filter_name] = graphene.Argument(field_type)
|
||||||
|
|
||||||
super().__init__(_type, args=filter_kwargs, *args, **kwargs)
|
super().__init__(_type, args=filter_kwargs, *args, **kwargs)
|
||||||
|
|
||||||
|
@ -3,6 +3,7 @@ import graphene
|
|||||||
from circuits.graphql.schema import CircuitsQuery
|
from circuits.graphql.schema import CircuitsQuery
|
||||||
from dcim.graphql.schema import DCIMQuery
|
from dcim.graphql.schema import DCIMQuery
|
||||||
from extras.graphql.schema import ExtrasQuery
|
from extras.graphql.schema import ExtrasQuery
|
||||||
|
from extras.registry import registry
|
||||||
from ipam.graphql.schema import IPAMQuery
|
from ipam.graphql.schema import IPAMQuery
|
||||||
from tenancy.graphql.schema import TenancyQuery
|
from tenancy.graphql.schema import TenancyQuery
|
||||||
from users.graphql.schema import UsersQuery
|
from users.graphql.schema import UsersQuery
|
||||||
@ -19,6 +20,7 @@ class Query(
|
|||||||
UsersQuery,
|
UsersQuery,
|
||||||
VirtualizationQuery,
|
VirtualizationQuery,
|
||||||
WirelessQuery,
|
WirelessQuery,
|
||||||
|
*registry['plugins']['graphql_schemas'], # Append plugin schemas
|
||||||
graphene.ObjectType
|
graphene.ObjectType
|
||||||
):
|
):
|
||||||
pass
|
pass
|
||||||
|
Reference in New Issue
Block a user