Forráskód Böngészése

Fixes #20638: Document bulk create support in OpenAPI schema (#20777)

* Fixes #20638: Document bulk create support in OpenAPI schema

POST operations on NetBoxModelViewSet endpoints accept both single
objects and arrays, but the schema only documented single objects.
This prevented API client generators from producing correct code.

Add explicit bulk_create_enabled flag to NetBoxModelViewSet and
update schema generation to emit oneOf for these endpoints.

* Address PR feedback

- Removed brittle serializer marking mechanism in favor of direct checks
  on behavior.
- Attempted to introduce a bulk_create action and then route to it on
  POST in NetBoxRouter, but ran in to several obstacles including
  breaking HTTP status code reporting in the schema. Opted to simply

* Remove unused bulk_create_enabled attr

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
Jason Novinger 2 hónapja
szülő
commit
82171fce7a
3 módosított fájl, 782 hozzáadás és 58 törlés
  1. 638 58
      contrib/openapi.json
  2. 36 0
      netbox/core/api/schema.py
  3. 108 0
      netbox/core/tests/test_openapi_schema.py

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 638 - 58
contrib/openapi.json


+ 36 - 0
netbox/core/api/schema.py

@@ -12,6 +12,7 @@ from drf_spectacular.utils import Direction
 
 
 from netbox.api.fields import ChoiceField
 from netbox.api.fields import ChoiceField
 from netbox.api.serializers import WritableNestedSerializer
 from netbox.api.serializers import WritableNestedSerializer
+from netbox.api.viewsets import NetBoxModelViewSet
 
 
 # see netbox.api.routers.NetBoxRouter
 # see netbox.api.routers.NetBoxRouter
 BULK_ACTIONS = ("bulk_destroy", "bulk_partial_update", "bulk_update")
 BULK_ACTIONS = ("bulk_destroy", "bulk_partial_update", "bulk_update")
@@ -49,6 +50,11 @@ class ChoiceFieldFix(OpenApiSerializerFieldExtension):
             )
             )
 
 
 
 
+def viewset_handles_bulk_create(view):
+    """Check if view automatically provides list-based bulk create"""
+    return isinstance(view, NetBoxModelViewSet)
+
+
 class NetBoxAutoSchema(AutoSchema):
 class NetBoxAutoSchema(AutoSchema):
     """
     """
     Overrides to drf_spectacular.openapi.AutoSchema to fix following issues:
     Overrides to drf_spectacular.openapi.AutoSchema to fix following issues:
@@ -128,6 +134,36 @@ class NetBoxAutoSchema(AutoSchema):
 
 
         return response_serializers
         return response_serializers
 
 
+    def _get_request_for_media_type(self, serializer, direction='request'):
+        """
+        Override to generate oneOf schema for serializers that support both
+        single object and array input (NetBoxModelViewSet POST operations).
+
+        Refs: #20638
+        """
+        # Get the standard schema first
+        schema, required = super()._get_request_for_media_type(serializer, direction)
+
+        # If this serializer supports arrays (marked in get_request_serializer),
+        # wrap the schema in oneOf to allow single object OR array
+        if (
+            direction == 'request' and
+            schema is not None and
+            getattr(self.view, 'action', None) == 'create' and
+            viewset_handles_bulk_create(self.view)
+        ):
+            return {
+                'oneOf': [
+                    schema,  # Single object
+                    {
+                        'type': 'array',
+                        'items': schema,  # Array of objects
+                    }
+                ]
+            }, required
+
+        return schema, required
+
     def _get_serializer_name(self, serializer, direction, bypass_extensions=False) -> str:
     def _get_serializer_name(self, serializer, direction, bypass_extensions=False) -> str:
         name = super()._get_serializer_name(serializer, direction, bypass_extensions)
         name = super()._get_serializer_name(serializer, direction, bypass_extensions)
 
 

+ 108 - 0
netbox/core/tests/test_openapi_schema.py

@@ -0,0 +1,108 @@
+"""
+Unit tests for OpenAPI schema generation.
+
+Refs: #20638
+"""
+import json
+from django.test import TestCase
+
+
+class OpenAPISchemaTestCase(TestCase):
+    """Tests for OpenAPI schema generation."""
+
+    def setUp(self):
+        """Fetch schema via API endpoint."""
+        response = self.client.get('/api/schema/', {'format': 'json'})
+        self.assertEqual(response.status_code, 200)
+        self.schema = json.loads(response.content)
+
+    def test_post_operation_documents_single_or_array(self):
+        """
+        POST operations on NetBoxModelViewSet endpoints should document
+        support for both single objects and arrays via oneOf.
+
+        Refs: #20638
+        """
+        # Test representative endpoints across different apps
+        test_paths = [
+            '/api/core/data-sources/',
+            '/api/dcim/sites/',
+            '/api/users/users/',
+            '/api/ipam/ip-addresses/',
+        ]
+
+        for path in test_paths:
+            with self.subTest(path=path):
+                operation = self.schema['paths'][path]['post']
+
+                # Get the request body schema
+                request_schema = operation['requestBody']['content']['application/json']['schema']
+
+                # Should have oneOf with two options
+                self.assertIn('oneOf', request_schema, f"POST {path} should have oneOf schema")
+                self.assertEqual(
+                    len(request_schema['oneOf']), 2,
+                    f"POST {path} oneOf should have exactly 2 options"
+                )
+
+                # First option: single object (has $ref or properties)
+                single_schema = request_schema['oneOf'][0]
+                self.assertTrue(
+                    '$ref' in single_schema or 'properties' in single_schema,
+                    f"POST {path} first oneOf option should be single object"
+                )
+
+                # Second option: array of objects
+                array_schema = request_schema['oneOf'][1]
+                self.assertEqual(
+                    array_schema['type'], 'array',
+                    f"POST {path} second oneOf option should be array"
+                )
+                self.assertIn('items', array_schema, f"POST {path} array should have items")
+
+    def test_bulk_update_operations_require_array_only(self):
+        """
+        Bulk update/patch operations should require arrays only, not oneOf.
+        They don't support single object input.
+
+        Refs: #20638
+        """
+        test_paths = [
+            '/api/dcim/sites/',
+            '/api/users/users/',
+        ]
+
+        for path in test_paths:
+            for method in ['put', 'patch']:
+                with self.subTest(path=path, method=method):
+                    operation = self.schema['paths'][path][method]
+                    request_schema = operation['requestBody']['content']['application/json']['schema']
+
+                    # Should be array-only, not oneOf
+                    self.assertNotIn(
+                        'oneOf', request_schema,
+                        f"{method.upper()} {path} should NOT have oneOf (array-only)"
+                    )
+                    self.assertEqual(
+                        request_schema['type'], 'array',
+                        f"{method.upper()} {path} should require array"
+                    )
+                    self.assertIn(
+                        'items', request_schema,
+                        f"{method.upper()} {path} array should have items"
+                    )
+
+    def test_bulk_delete_requires_array(self):
+        """
+        Bulk delete operations should require arrays.
+
+        Refs: #20638
+        """
+        path = '/api/dcim/sites/'
+        operation = self.schema['paths'][path]['delete']
+        request_schema = operation['requestBody']['content']['application/json']['schema']
+
+        # Should be array-only
+        self.assertNotIn('oneOf', request_schema, "DELETE should NOT have oneOf")
+        self.assertEqual(request_schema['type'], 'array', "DELETE should require array")
+        self.assertIn('items', request_schema, "DELETE array should have items")

Nem az összes módosított fájl került megjelenítésre, mert túl sok fájl változott