Arthur 2 dias atrás
pai
commit
18a8bae398

+ 1 - 0
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`                | 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`)                                |
 | `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`)                                           |
 | `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`)           |
 | `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.

+ 8 - 0
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/`.
 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
 !!! 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.
     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.

+ 19 - 3
netbox/utilities/api.py

@@ -1,3 +1,5 @@
+import logging
+
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.core.exceptions import (
 from django.core.exceptions import (
     FieldDoesNotExist,
     FieldDoesNotExist,
@@ -22,6 +24,8 @@ from netbox.registry import registry
 from .query import count_related, dict_to_filter_params
 from .query import count_related, dict_to_filter_params
 from .string import title
 from .string import title
 
 
+logger = logging.getLogger('netbox.utilities.api')
+
 __all__ = (
 __all__ = (
     'IsSuperuser',
     'IsSuperuser',
     'get_annotations_for_serializer',
     '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.
     wins. If no resolver matches, the default import-path lookup runs.
     """
     """
     for resolver in registry['serializer_resolvers']:
     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('.')
     app_label, model_name = model._meta.label.split('.')
     serializer_name = f'{app_label}.api.serializers.{prefix}{model_name}Serializer'
     serializer_name = f'{app_label}.api.serializers.{prefix}{model_name}Serializer'

+ 36 - 33
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 django.urls import reverse
 from drf_spectacular.drainage import GENERATOR_STATS
 from drf_spectacular.drainage import GENERATOR_STATS
 from rest_framework import status
 from rest_framework import status
+from rest_framework.serializers import Serializer
 
 
 from core.models import ObjectType
 from core.models import ObjectType
+from dcim.api.serializers import SiteSerializer
 from dcim.models import Region, Site
 from dcim.models import Region, Site
 from extras.choices import CustomFieldTypeChoices
 from extras.choices import CustomFieldTypeChoices
 from extras.models import CustomField
 from extras.models import CustomField
 from ipam.models import VLAN
 from ipam.models import VLAN
 from netbox.api.serializers import BaseModelSerializer
 from netbox.api.serializers import BaseModelSerializer
 from netbox.config import get_config
 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
 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):
 class SerializerResolverRegistryTestCase(TestCase):
     """
     """
     Verify that registered serializer resolvers are consulted before the
     Verify that registered serializer resolvers are consulted before the
@@ -483,67 +495,44 @@ class SerializerResolverRegistryTestCase(TestCase):
     """
     """
 
 
     def setUp(self):
     def setUp(self):
-        from netbox.registry import registry
-
         # Snapshot and clear the resolver list so each test starts from a
         # 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.
         # known state and can't leak resolvers into the rest of the suite.
         self._saved_resolvers = list(registry['serializer_resolvers'])
         self._saved_resolvers = list(registry['serializer_resolvers'])
         registry['serializer_resolvers'].clear()
         registry['serializer_resolvers'].clear()
 
 
     def tearDown(self):
     def tearDown(self):
-        from netbox.registry import registry
-
         registry['serializer_resolvers'].clear()
         registry['serializer_resolvers'].clear()
         registry['serializer_resolvers'].extend(self._saved_resolvers)
         registry['serializer_resolvers'].extend(self._saved_resolvers)
 
 
     def test_default_lookup_when_no_resolvers_registered(self):
     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)
         self.assertIs(get_serializer_for_model(Site), SiteSerializer)
 
 
     def test_registered_resolver_overrides_default(self):
     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):
     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)
         register_serializer_resolver(lambda model, prefix='': None)
 
 
         self.assertIs(get_serializer_for_model(Site), SiteSerializer)
         self.assertIs(get_serializer_for_model(Site), SiteSerializer)
 
 
     def test_resolvers_tried_in_registration_order(self):
     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.
         # First resolver only handles VLAN; second handles everything else.
         register_serializer_resolver(
         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):
     def test_resolver_receives_prefix(self):
-        from netbox.plugins import register_serializer_resolver
-        from utilities.api import get_serializer_for_model
-
         seen = {}
         seen = {}
 
 
         def resolver(model, prefix=''):
         def resolver(model, prefix=''):
             seen['prefix'] = prefix
             seen['prefix'] = prefix
-            return object()
+            return _ResolvedSerializerA
 
 
         register_serializer_resolver(resolver)
         register_serializer_resolver(resolver)
         get_serializer_for_model(Site, prefix='Nested')
         get_serializer_for_model(Site, prefix='Nested')
@@ -551,11 +540,25 @@ class SerializerResolverRegistryTestCase(TestCase):
         self.assertEqual(seen['prefix'], 'Nested')
         self.assertEqual(seen['prefix'], 'Nested')
 
 
     def test_register_rejects_non_callable(self):
     def test_register_rejects_non_callable(self):
-        from netbox.plugins import register_serializer_resolver
-
         with self.assertRaises(TypeError):
         with self.assertRaises(TypeError):
             register_serializer_resolver('not a callable')
             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):
 class APITrailingSlashTestCase(APITestCase):
     """
     """