This commit is contained in:
Arthur 2026-05-20 11:46:54 -07:00 committed by Jeremy Stretch
parent 0499bb7616
commit b09e8a1808
4 changed files with 64 additions and 36 deletions

View File

@ -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.

View File

@ -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.

View File

@ -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'

View File

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