From 004178a299fa8b3c1fc07baeaa0522747171b777 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 28 May 2026 09:29:43 -0400 Subject: [PATCH] Fixes #21091: Correct OpenAPI schema for rendering config contexts --- netbox/extras/api/mixins.py | 36 +++++++++++---- .../api/serializers_/configtemplates.py | 44 +++++++++++++++++++ netbox/extras/api/views.py | 24 ++++++++-- 3 files changed, 93 insertions(+), 11 deletions(-) diff --git a/netbox/extras/api/mixins.py b/netbox/extras/api/mixins.py index 6a3713be4..e8b637ae3 100644 --- a/netbox/extras/api/mixins.py +++ b/netbox/extras/api/mixins.py @@ -1,4 +1,5 @@ -from django.utils.translation import gettext as _ +from django.utils.translation import gettext_lazy as _ +from drf_spectacular.utils import OpenApiResponse, OpenApiTypes, extend_schema from rest_framework.decorators import action from rest_framework.renderers import JSONRenderer from rest_framework.response import Response @@ -8,7 +9,7 @@ from extras.models import ConfigTemplate from netbox.api.authentication import TokenWritePermission from netbox.api.renderers import TextRenderer -from .serializers import ConfigTemplateSerializer +from .serializers import RenderConfigInputSerializer, RenderedConfigSerializer __all__ = ( 'ConfigContextQuerySetMixin', @@ -56,12 +57,11 @@ class ConfigTemplateRenderMixin: if request.accepted_renderer.format == 'txt': return Response(output) - template_serializer = ConfigTemplateSerializer(configtemplate, nested=True, context={'request': request}) - - return Response({ - 'configtemplate': template_serializer.data, - 'content': output - }) + serializer = RenderedConfigSerializer( + instance={'configtemplate': configtemplate, 'content': output}, + context={'request': request}, + ) + return Response(serializer.data) class RenderConfigMixin(ConfigTemplateRenderMixin): @@ -75,6 +75,26 @@ class RenderConfigMixin(ConfigTemplateRenderMixin): return [TokenWritePermission()] return super().get_permissions() + @extend_schema( + request=RenderConfigInputSerializer, + responses={ + 200: OpenApiResponse( + response=RenderedConfigSerializer, + description=_( + "The rendered config template. When the client requests `text/plain`, the raw " + "rendered content is returned in place of the JSON object." + ), + ), + 400: OpenApiResponse( + response=OpenApiTypes.OBJECT, + description=_("No config template could be resolved for this object."), + ), + 500: OpenApiResponse( + response=OpenApiTypes.OBJECT, + description=_("An error occurred while rendering the config template."), + ), + }, + ) @action(detail=True, methods=['post'], url_path='render-config', renderer_classes=[JSONRenderer, TextRenderer]) def render_config(self, request, pk): """ diff --git a/netbox/extras/api/serializers_/configtemplates.py b/netbox/extras/api/serializers_/configtemplates.py index 4bf4608ac..7ac542f5d 100644 --- a/netbox/extras/api/serializers_/configtemplates.py +++ b/netbox/extras/api/serializers_/configtemplates.py @@ -1,3 +1,7 @@ +from django.utils.translation import gettext_lazy as _ +from drf_spectacular.extensions import OpenApiSerializerExtension +from rest_framework import serializers + from core.api.serializers_.data import DataFileSerializer, DataSourceSerializer from extras.models import ConfigTemplate from netbox.api.serializers import ChangeLogMessageSerializer, ValidatedModelSerializer @@ -6,6 +10,8 @@ from users.api.serializers_.mixins import OwnerMixin __all__ = ( 'ConfigTemplateSerializer', + 'RenderConfigInputSerializer', + 'RenderedConfigSerializer', ) @@ -32,3 +38,41 @@ class ConfigTemplateSerializer( 'data_file', 'auto_sync_enabled', 'data_synced', 'owner', 'tags', 'created', 'last_updated', ] brief_fields = ('id', 'url', 'display', 'name', 'description') + + +class RenderConfigInputSerializer(serializers.Serializer): + """ + Describes the request body for the device/VM /render-config/ endpoints. Any additional keys + supplied are passed through as context variables to the rendered template. + """ + config_template_id = serializers.IntegerField( + required=False, + help_text=_( + "Optional ID of the ConfigTemplate to render. If omitted, the object's assigned " + "config template is used." + ) + ) + + +class RenderConfigInputSerializerExtension(OpenApiSerializerExtension): + """ + Augment the schema for `RenderConfigInputSerializer` to advertise that additional keys + (beyond `config_template_id`) are accepted and forwarded as template context variables. + """ + target_class = 'extras.api.serializers_.configtemplates.RenderConfigInputSerializer' + + def map_serializer(self, auto_schema, direction): + schema = auto_schema._map_serializer(self.target, direction, bypass_extensions=True) + schema['additionalProperties'] = True + return schema + + +class RenderedConfigSerializer(serializers.Serializer): + """ + Describes the JSON response returned by the /render-config/ and /render/ endpoints. + """ + configtemplate = ConfigTemplateSerializer(read_only=True, nested=True) + content = serializers.CharField( + read_only=True, + help_text=_("The rendered template output.") + ) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 5149d8b3a..faf8a9bec 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -1,6 +1,7 @@ from django.http import Http404 from django.shortcuts import get_object_or_404 -from drf_spectacular.utils import extend_schema, extend_schema_view +from django.utils.translation import gettext_lazy as _ +from drf_spectacular.utils import OpenApiResponse, OpenApiTypes, extend_schema, extend_schema_view from rest_framework import status from rest_framework.decorators import action from rest_framework.exceptions import PermissionDenied @@ -245,11 +246,28 @@ class ConfigTemplateViewSet(SyncedDataMixin, ConfigTemplateRenderMixin, NetBoxMo return [TokenWritePermission()] return super().get_permissions() + @extend_schema( + request=OpenApiTypes.OBJECT, + responses={ + 200: OpenApiResponse( + response=serializers.RenderedConfigSerializer, + description=_( + "The rendered config template. When the client requests `text/plain`, the raw " + "rendered content is returned in place of the JSON object." + ), + ), + 500: OpenApiResponse( + response=OpenApiTypes.OBJECT, + description=_("An error occurred while rendering the config template."), + ), + }, + ) @action(detail=True, methods=['post'], renderer_classes=[JSONRenderer, TextRenderer]) def render(self, request, pk): """ - Render a ConfigTemplate using the context data provided (if any). If the client requests "text/plain" data, - return the raw rendered content, rather than serialized JSON. + Render a ConfigTemplate using the context data provided (if any). The request body should be a + mapping of context variables to make available to the template. If the client requests "text/plain" + data, return the raw rendered content, rather than serialized JSON. """ # Override restrict() on the default queryset to enforce the render & view actions self.queryset = self.queryset.model.objects.restrict(request.user, 'render').restrict(request.user, 'view')