diff --git a/docs/plugins/development/index.md b/docs/plugins/development/index.md index b94639300..a70d60a72 100644 --- a/docs/plugins/development/index.md +++ b/docs/plugins/development/index.md @@ -122,6 +122,7 @@ NetBox looks for the `config` variable within a plugin's `__init__.py` to load i | `menu` | The dotted path to a top-level navigation menu provided by the plugin (default: `navigation.menu`) | | `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`) | +| `serializer_resolver` | The dotted path to a callable that resolves REST API serializers for the plugin's models (default: `api.serializers.serializer_resolver`) | | `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/docs/plugins/development/rest-api.md b/docs/plugins/development/rest-api.md index 875289d94..8168fb4dd 100644 --- a/docs/plugins/development/rest-api.md +++ b/docs/plugins/development/rest-api.md @@ -95,5 +95,13 @@ urlpatterns = router.urls This will make the plugin's view accessible at `/api/plugins/my-plugin/my-model/`. +## Serializer Resolvers + +NetBox resolves the REST API serializer for a model by importing `{app_label}.api.serializers.{ModelName}Serializer`. Plugins whose models are generated dynamically (and therefore have no importable serializer at that path), or that need to override resolution for specific models, may register a **serializer resolver**. + +A resolver is a callable with the signature `resolver(model, prefix='') -> Serializer subclass or None`. Resolvers are consulted in registration order before the default import-path lookup; the first non-`None` return wins. If no resolver matches, the default lookup runs unchanged. + +Register a resolver either by setting the `serializer_resolver` attribute on the plugin's `PluginConfig` (loaded from `api.serializers.serializer_resolver` by default) or by calling `netbox.plugins.register_serializer_resolver()` directly. A resolver that raises an exception, or returns a value that is not a `Serializer` subclass, is skipped and logged. + !!! warning The examples provided here are intended to serve as a minimal reference implementation only. This documentation does not address authentication, performance, or myriad other concerns that plugin authors may need to address. diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 07865d69b..c438ad3dc 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -1,3 +1,5 @@ +import logging + from django.contrib.contenttypes.fields import GenericForeignKey from django.core.exceptions import ( FieldDoesNotExist, @@ -22,6 +24,8 @@ from netbox.registry import registry from .query import count_related, dict_to_filter_params from .string import title +logger = logging.getLogger('netbox.utilities.api') + __all__ = ( 'IsSuperuser', 'get_annotations_for_serializer', @@ -54,9 +58,21 @@ def get_serializer_for_model(model, prefix=''): wins. If no resolver matches, the default import-path lookup runs. """ for resolver in registry['serializer_resolvers']: - serializer = resolver(model, prefix=prefix) - if serializer is not None: - return serializer + try: + serializer = resolver(model, prefix=prefix) + except Exception: + # A buggy resolver must not break serializer lookup for the rest of NetBox. + logger.exception("Serializer resolver %r raised an exception; skipping.", resolver) + continue + if serializer is None: + continue + if not (isinstance(serializer, type) and issubclass(serializer, Serializer)): + logger.warning( + "Serializer resolver %r returned %r, which is not a Serializer subclass; skipping.", + resolver, serializer, + ) + continue + return serializer app_label, model_name = model._meta.label.split('.') serializer_name = f'{app_label}.api.serializers.{prefix}{model_name}Serializer' diff --git a/netbox/utilities/tests/test_api.py b/netbox/utilities/tests/test_api.py index 51fedcc9d..f41e9505f 100644 --- a/netbox/utilities/tests/test_api.py +++ b/netbox/utilities/tests/test_api.py @@ -2,15 +2,19 @@ from django.test import Client, TestCase, override_settings, tag from django.urls import reverse from drf_spectacular.drainage import GENERATOR_STATS from rest_framework import status +from rest_framework.serializers import Serializer from core.models import ObjectType +from dcim.api.serializers import SiteSerializer from dcim.models import Region, Site from extras.choices import CustomFieldTypeChoices from extras.models import CustomField from ipam.models import VLAN from netbox.api.serializers import BaseModelSerializer from netbox.config import get_config -from utilities.api import get_prefetches_for_serializer, get_view_name +from netbox.plugins import register_serializer_resolver +from netbox.registry import registry +from utilities.api import get_prefetches_for_serializer, get_serializer_for_model, get_view_name from utilities.testing import APITestCase, disable_warnings @@ -476,6 +480,14 @@ class GetPrefetchesForSerializerTestCase(TestCase): ) +class _ResolvedSerializerA(Serializer): + pass + + +class _ResolvedSerializerB(Serializer): + pass + + class SerializerResolverRegistryTestCase(TestCase): """ Verify that registered serializer resolvers are consulted before the @@ -483,67 +495,44 @@ class SerializerResolverRegistryTestCase(TestCase): """ def setUp(self): - from netbox.registry import registry - # Snapshot and clear the resolver list so each test starts from a # known state and can't leak resolvers into the rest of the suite. self._saved_resolvers = list(registry['serializer_resolvers']) registry['serializer_resolvers'].clear() def tearDown(self): - from netbox.registry import registry - registry['serializer_resolvers'].clear() registry['serializer_resolvers'].extend(self._saved_resolvers) def test_default_lookup_when_no_resolvers_registered(self): - from dcim.api.serializers import SiteSerializer - from utilities.api import get_serializer_for_model - self.assertIs(get_serializer_for_model(Site), SiteSerializer) def test_registered_resolver_overrides_default(self): - from netbox.plugins import register_serializer_resolver - from utilities.api import get_serializer_for_model + register_serializer_resolver(lambda model, prefix='': _ResolvedSerializerA) - sentinel = object() - register_serializer_resolver(lambda model, prefix='': sentinel) - - self.assertIs(get_serializer_for_model(Site), sentinel) + self.assertIs(get_serializer_for_model(Site), _ResolvedSerializerA) def test_resolver_returning_none_falls_through_to_default(self): - from dcim.api.serializers import SiteSerializer - from netbox.plugins import register_serializer_resolver - from utilities.api import get_serializer_for_model - register_serializer_resolver(lambda model, prefix='': None) self.assertIs(get_serializer_for_model(Site), SiteSerializer) def test_resolvers_tried_in_registration_order(self): - from netbox.plugins import register_serializer_resolver - from utilities.api import get_serializer_for_model - - first = object() - second = object() # First resolver only handles VLAN; second handles everything else. register_serializer_resolver( - lambda model, prefix='': first if model is VLAN else None + lambda model, prefix='': _ResolvedSerializerA if model is VLAN else None ) - register_serializer_resolver(lambda model, prefix='': second) + register_serializer_resolver(lambda model, prefix='': _ResolvedSerializerB) - self.assertIs(get_serializer_for_model(VLAN), first) - self.assertIs(get_serializer_for_model(Site), second) + self.assertIs(get_serializer_for_model(VLAN), _ResolvedSerializerA) + self.assertIs(get_serializer_for_model(Site), _ResolvedSerializerB) def test_resolver_receives_prefix(self): - from netbox.plugins import register_serializer_resolver - from utilities.api import get_serializer_for_model - seen = {} def resolver(model, prefix=''): seen['prefix'] = prefix - return object() + return _ResolvedSerializerA register_serializer_resolver(resolver) get_serializer_for_model(Site, prefix='Nested') @@ -551,11 +540,25 @@ class SerializerResolverRegistryTestCase(TestCase): self.assertEqual(seen['prefix'], 'Nested') def test_register_rejects_non_callable(self): - from netbox.plugins import register_serializer_resolver - with self.assertRaises(TypeError): register_serializer_resolver('not a callable') + def test_raising_resolver_is_skipped(self): + def broken_resolver(model, prefix=''): + raise RuntimeError("intentional failure") + + register_serializer_resolver(broken_resolver) + register_serializer_resolver(lambda model, prefix='': _ResolvedSerializerA) + + with self.assertLogs('netbox.utilities.api', level='ERROR'): + self.assertIs(get_serializer_for_model(Site), _ResolvedSerializerA) + + def test_resolver_returning_non_serializer_is_skipped(self): + register_serializer_resolver(lambda model, prefix='': object()) + + with self.assertLogs('netbox.utilities.api', level='WARNING'): + self.assertIs(get_serializer_for_model(Site), SiteSerializer) + class APITrailingSlashTestCase(APITestCase): """