Arthur 2 дней назад
Родитель
Сommit
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_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.

+ 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/`.
 
+## 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.

+ 19 - 3
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'

+ 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 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):
     """