Repository: netbox-community/netbox

Issue:
Add support for "limit" query parameter to available VLANs endpoint
### NetBox version

v3.2.9

### Python version

3.10

### Steps to Reproduce

1. Create a new VlanGroup with minvid set to 1 and maxvid set to 4094
2. Call the available-vlans endpoint with ?limit=1

### Expected Behavior

Get back one available-vlan object

### Observed Behavior

Get back 4094 available-vlan objects, which in my case causes partial responses to be send which leads to invalid JSON with all associated issues. This only seems to happen with the Guzzle PHP HTTP client, works "fine" in the browser, except for the fact that the request obviously takes ages.

Available-prefix works a bit different and available-ips does seem to support the limit parameter.


Discussion:
Pagination is not currently supported for available VLANs because they do not equate to records in the database. The same is true for e.g. available IP addresses. (The `available-ips/` endpoints do support the `limit` query parameter, but not pagination of results.)

We could add support for the `limit` parameter pretty easily, however it would involve abstracting the logic currently in place under AvailableIPAddressesView's `get()` method into a reusable function. It should probably also be replicated to AvailablePrefixesView.
Ye I noticed the lack of pagination after submitting this. Isolating the limit code in a separate function would be nice, although I would need a bit of guidance as to where I could put that function best.

As for available-prefix, I think that is a bit of an exception. Rather than returning all available prefixes it only returns the set of the biggest possible prefixes. Also the write variant works different because you just request a prefix of a certain size without specifying the actual prefix unlike available-ips/vlans.

Resolving patch:
diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py
--- a/netbox/ipam/api/views.py
+++ b/netbox/ipam/api/views.py
@@ -174,6 +174,21 @@ class L2VPNTerminationViewSet(NetBoxModelViewSet):
 # Views
 #
 
+def get_results_limit(request):
+    """
+    Return the lesser of the specified limit (if any) and the configured MAX_PAGE_SIZE.
+    """
+    config = get_config()
+    try:
+        limit = int(request.query_params.get('limit', config.PAGINATE_COUNT)) or config.MAX_PAGE_SIZE
+    except ValueError:
+        limit = config.PAGINATE_COUNT
+    if config.MAX_PAGE_SIZE:
+        limit = min(limit, config.MAX_PAGE_SIZE)
+
+    return limit
+
+
 class AvailablePrefixesView(ObjectValidationMixin, APIView):
     queryset = Prefix.objects.all()
 
@@ -265,16 +280,7 @@ def get_parent(self, request, pk):
     @swagger_auto_schema(responses={200: serializers.AvailableIPSerializer(many=True)})
     def get(self, request, pk):
         parent = self.get_parent(request, pk)
-        config = get_config()
-        PAGINATE_COUNT = config.PAGINATE_COUNT
-        MAX_PAGE_SIZE = config.MAX_PAGE_SIZE
-
-        try:
-            limit = int(request.query_params.get('limit', PAGINATE_COUNT))
-        except ValueError:
-            limit = PAGINATE_COUNT
-        if MAX_PAGE_SIZE:
-            limit = min(limit, MAX_PAGE_SIZE)
+        limit = get_results_limit(request)
 
         # Calculate available IPs within the parent
         ip_list = []
@@ -357,8 +363,9 @@ class AvailableVLANsView(ObjectValidationMixin, APIView):
     @swagger_auto_schema(responses={200: serializers.AvailableVLANSerializer(many=True)})
     def get(self, request, pk):
         vlangroup = get_object_or_404(VLANGroup.objects.restrict(request.user), pk=pk)
-        available_vlans = vlangroup.get_available_vids()
+        limit = get_results_limit(request)
 
+        available_vlans = vlangroup.get_available_vids()[:limit]
         serializer = serializers.AvailableVLANSerializer(available_vlans, many=True, context={
             'request': request,
             'group': vlangroup,


Test patch:
diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py
--- a/netbox/ipam/tests/test_api.py
+++ b/netbox/ipam/tests/test_api.py
@@ -699,9 +699,18 @@ def test_list_available_vlans(self):
         """
         Test retrieval of all available VLANs within a group.
         """
+        MIN_VID = 100
+        MAX_VID = 199
+
         self.add_permissions('ipam.view_vlangroup', 'ipam.view_vlan')
-        vlangroup = VLANGroup.objects.first()
+        vlangroup = VLANGroup.objects.create(
+            name='VLAN Group X',
+            slug='vlan-group-x',
+            min_vid=MIN_VID,
+            max_vid=MAX_VID
+        )
 
+        # Create a set of VLANs within the group
         vlans = (
             VLAN(vid=10, name='VLAN 10', group=vlangroup),
             VLAN(vid=20, name='VLAN 20', group=vlangroup),
@@ -711,13 +720,17 @@ def test_list_available_vlans(self):
 
         # Retrieve all available VLANs
         url = reverse('ipam-api:vlangroup-available-vlans', kwargs={'pk': vlangroup.pk})
-        response = self.client.get(url, **self.header)
-
-        self.assertEqual(len(response.data), 4094 - len(vlans))
+        response = self.client.get(f'{url}?limit=0', **self.header)
+        self.assertEqual(len(response.data), MAX_VID - MIN_VID + 1)
         available_vlans = {vlan['vid'] for vlan in response.data}
         for vlan in vlans:
             self.assertNotIn(vlan.vid, available_vlans)
 
+        # Retrieve a maximum number of available VLANs
+        url = reverse('ipam-api:vlangroup-available-vlans', kwargs={'pk': vlangroup.pk})
+        response = self.client.get(f'{url}?limit=10', **self.header)
+        self.assertEqual(len(response.data), 10)
+
     def test_create_single_available_vlan(self):
         """
         Test the creation of a single available VLAN.