This is an automated email from the ASF dual-hosted git repository.

sureshanaparti pushed a commit to branch 4.22
in repository https://gitbox.apache.org/repos/asf/cloudstack.git


The following commit(s) were added to refs/heads/4.22 by this push:
     new e0fe953791b fix: NSX SDK list operations are pageable: the API returns 
a non-null and non-empty (#12834)
e0fe953791b is described below

commit e0fe953791bab5b8e7547c7e559b6f655a3f46c3
Author: Daniil Zhyliaiev <[email protected]>
AuthorDate: Thu Apr 16 11:45:30 2026 +0300

    fix: NSX SDK list operations are pageable: the API returns a non-null and 
non-empty (#12834)
    
    `cursor` field when more pages are available. The previous implementation 
only
    fetched the first page and ignored pagination.
    
    This change updates the list retrieval flow to:
    - follow the `cursor` chain until no further pages exist
    - accumulate items from all pages
    - return a single merged result to the caller
    
    This ensures that list operations return the complete dataset rather than 
just
    the first page.
    
    Co-authored-by: Andrey Volchkov <[email protected]>
---
 .../apache/cloudstack/service/NsxApiClient.java    | 153 ++++++++++++++------
 .../apache/cloudstack/service/PagedFetcher.java    |  82 +++++++++++
 .../cloudstack/service/PagedFetcherTest.java       | 156 +++++++++++++++++++++
 3 files changed, 350 insertions(+), 41 deletions(-)

diff --git 
a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/NsxApiClient.java
 
b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/NsxApiClient.java
index 868417f1d60..4d78f2a0ab2 100644
--- 
a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/NsxApiClient.java
+++ 
b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/NsxApiClient.java
@@ -44,6 +44,7 @@ import com.vmware.nsx_policy.infra.tier_0s.LocaleServices;
 import com.vmware.nsx_policy.infra.tier_1s.nat.NatRules;
 import com.vmware.nsx_policy.model.ApiError;
 import com.vmware.nsx_policy.model.DhcpRelayConfig;
+import com.vmware.nsx_policy.model.EnforcementPoint;
 import com.vmware.nsx_policy.model.EnforcementPointListResult;
 import com.vmware.nsx_policy.model.Group;
 import com.vmware.nsx_policy.model.GroupListResult;
@@ -64,12 +65,13 @@ import com.vmware.nsx_policy.model.PathExpression;
 import com.vmware.nsx_policy.model.PolicyGroupMembersListResult;
 import com.vmware.nsx_policy.model.PolicyNatRule;
 import com.vmware.nsx_policy.model.PolicyNatRuleListResult;
+import com.vmware.nsx_policy.model.PolicyGroupMemberDetails;
 import com.vmware.nsx_policy.model.Rule;
 import com.vmware.nsx_policy.model.SecurityPolicy;
 import com.vmware.nsx_policy.model.Segment;
 import com.vmware.nsx_policy.model.SegmentSubnet;
 import com.vmware.nsx_policy.model.ServiceListResult;
-import com.vmware.nsx_policy.model.SiteListResult;
+import com.vmware.nsx_policy.model.Site;
 import com.vmware.nsx_policy.model.Tier1;
 import com.vmware.vapi.bindings.Service;
 import com.vmware.vapi.bindings.Structure;
@@ -99,6 +101,7 @@ import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
 import java.util.function.Function;
+import java.util.function.Predicate;
 import java.util.stream.Collectors;
 
 import static java.util.stream.Collectors.toSet;
@@ -285,16 +288,18 @@ public class NsxApiClient {
             Tier1s tier1service = (Tier1s) nsxService.apply(Tier1s.class);
             return tier1service.get(tier1GatewayId);
         } catch (Exception e) {
-            logger.debug(String.format("NSX Tier-1 gateway with name: %s not 
found", tier1GatewayId));
+            logger.debug("NSX Tier-1 gateway with name: {} not found", 
tier1GatewayId);
         }
         return null;
     }
 
-    private List<com.vmware.nsx_policy.model.LocaleServices> 
getTier0LocalServices(String tier0Gateway) {
+    private Optional<com.vmware.nsx_policy.model.LocaleServices> 
findTier0LocalServices(String tier0Gateway) {
         try {
             LocaleServices tier0LocaleServices = (LocaleServices) 
nsxService.apply(LocaleServices.class);
-            LocaleServicesListResult result = 
tier0LocaleServices.list(tier0Gateway, null, false, null, null, null, null);
-            return result.getResults();
+            LocaleServicesListResult result = 
tier0LocaleServices.list(tier0Gateway, null, false, null, 1L, null, null);
+            return Optional.ofNullable(result.getResults())
+                    .filter(Predicate.not(List::isEmpty))
+                    .map(l -> l.get(0));
         } catch (Exception e) {
             throw new CloudRuntimeException(String.format("Failed to fetch 
locale services for tier gateway %s due to %s", tier0Gateway, e.getMessage()));
         }
@@ -305,10 +310,13 @@ public class NsxApiClient {
      */
     private void createTier1LocaleServices(String tier1Id, String edgeCluster, 
String tier0Gateway) {
         try {
-            List<com.vmware.nsx_policy.model.LocaleServices> localeServices = 
getTier0LocalServices(tier0Gateway);
+            Optional<com.vmware.nsx_policy.model.LocaleServices> 
localeServices = findTier0LocalServices(tier0Gateway);
+            if (localeServices.isEmpty()) {
+                throw new CloudRuntimeException(String.format("Failed to find 
locale services for tier-0 gateway %s", tier0Gateway));
+            }
             com.vmware.nsx_policy.infra.tier_1s.LocaleServices 
tier1LocalService = (com.vmware.nsx_policy.infra.tier_1s.LocaleServices) 
nsxService.apply(com.vmware.nsx_policy.infra.tier_1s.LocaleServices.class);
             com.vmware.nsx_policy.model.LocaleServices localeService = new 
com.vmware.nsx_policy.model.LocaleServices.Builder()
-                    
.setEdgeClusterPath(localeServices.get(0).getEdgeClusterPath()).build();
+                    
.setEdgeClusterPath(localeServices.get().getEdgeClusterPath()).build();
             tier1LocalService.patch(tier1Id, TIER_1_LOCALE_SERVICE_ID, 
localeService);
         } catch (Error error) {
             throw new CloudRuntimeException(String.format("Failed to 
instantiate tier-1 gateway %s in edge cluster %s", tier1Id, edgeCluster));
@@ -330,7 +338,7 @@ public class NsxApiClient {
         String tier0GatewayPath = TIER_0_GATEWAY_PATH_PREFIX + tier0Gateway;
         Tier1 tier1 = getTier1Gateway(name);
         if (tier1 != null) {
-            logger.info(String.format("VPC network with name %s exists in NSX 
zone", name));
+            logger.info("VPC network with name {} exists in NSX zone", name);
             return;
         }
 
@@ -362,7 +370,7 @@ public class NsxApiClient {
         com.vmware.nsx_policy.infra.tier_1s.LocaleServices localeService = 
(com.vmware.nsx_policy.infra.tier_1s.LocaleServices)
                 
nsxService.apply(com.vmware.nsx_policy.infra.tier_1s.LocaleServices.class);
         if (getTier1Gateway(tier1Id) == null) {
-            logger.warn(String.format("The Tier 1 Gateway %s does not exist, 
cannot be removed", tier1Id));
+            logger.warn("The Tier 1 Gateway {} does not exist, cannot be 
removed", tier1Id);
             return;
         }
         removeTier1GatewayNatRules(tier1Id);
@@ -373,13 +381,21 @@ public class NsxApiClient {
 
     private void removeTier1GatewayNatRules(String tier1Id) {
         NatRules natRulesService = (NatRules) nsxService.apply(NatRules.class);
-        PolicyNatRuleListResult result = natRulesService.list(tier1Id, NAT_ID, 
null, false, null, null, null, null);
-        List<PolicyNatRule> natRules = result.getResults();
+        List<PolicyNatRule> natRules = PagedFetcher.<PolicyNatRuleListResult, 
PolicyNatRule>withPageFetcher(
+                cursor -> natRulesService.list(tier1Id, NAT_ID, cursor, false, 
null, null, null, null)
+                ).cursorExtractor(PolicyNatRuleListResult::getCursor)
+                .itemsExtractor(PolicyNatRuleListResult::getResults)
+                .itemsSetter((page, allItems) -> {
+                    page.setResults(allItems);
+                    page.setResultCount((long) allItems.size());
+                })
+                .fetchAll()
+                .getResults();
         if (CollectionUtils.isEmpty(natRules)) {
-            logger.debug(String.format("Didn't find any NAT rule to remove on 
the Tier 1 Gateway %s", tier1Id));
+            logger.debug("Didn't find any NAT rule to remove on the Tier 1 
Gateway {}", tier1Id);
         } else {
             for (PolicyNatRule natRule : natRules) {
-                logger.debug(String.format("Removing NAT rule %s from Tier 1 
Gateway %s", natRule.getId(), tier1Id));
+                logger.debug("Removing NAT rule {} from Tier 1 Gateway {}", 
natRule.getId(), tier1Id);
                 natRulesService.delete(tier1Id, NAT_ID, natRule.getId());
             }
         }
@@ -387,38 +403,45 @@ public class NsxApiClient {
     }
 
     public String getDefaultSiteId() {
-        SiteListResult sites = getSites();
-        if (CollectionUtils.isEmpty(sites.getResults())) {
+        Optional<Site> site = findFirstSite();
+        if (site.isEmpty()) {
             String errorMsg = "No sites are found in the linked NSX 
infrastructure";
             logger.error(errorMsg);
             throw new CloudRuntimeException(errorMsg);
         }
-        return sites.getResults().get(0).getId();
+        return site.get().getId();
     }
 
-    protected SiteListResult getSites() {
+    protected Optional<Site> findFirstSite() {
         try {
             Sites sites = (Sites) nsxService.apply(Sites.class);
-            return sites.list(null, false, null, null, null, null);
+            List<Site> siteList = sites.list(null, false, null, 1L, null, null)
+                    .getResults();
+            return Optional.ofNullable(siteList)
+                    .filter(Predicate.not(List::isEmpty))
+                    .map(l -> l.get(0));
         } catch (Exception e) {
             throw new CloudRuntimeException(String.format("Failed to fetch 
sites list due to %s", e.getMessage()));
         }
     }
 
     public String getDefaultEnforcementPointPath(String siteId) {
-        EnforcementPointListResult epList = getEnforcementPoints(siteId);
-        if (CollectionUtils.isEmpty(epList.getResults())) {
+        Optional<EnforcementPoint> ep = findFirstEnforcementPoint(siteId);
+        if (ep.isEmpty()) {
             String errorMsg = String.format("No enforcement points are found 
in the linked NSX infrastructure for site ID %s", siteId);
             logger.error(errorMsg);
             throw new CloudRuntimeException(errorMsg);
         }
-        return epList.getResults().get(0).getPath();
+        return ep.get().getPath();
     }
 
-    protected EnforcementPointListResult getEnforcementPoints(String siteId) {
+    protected Optional<EnforcementPoint> findFirstEnforcementPoint(String 
siteId) {
         try {
             EnforcementPoints enforcementPoints = (EnforcementPoints) 
nsxService.apply(EnforcementPoints.class);
-            return enforcementPoints.list(siteId, null, false, null, null, 
null, null);
+            EnforcementPointListResult result = enforcementPoints.list(siteId, 
null, false, null, 1L, null, null);
+            return Optional.ofNullable(result.getResults())
+                    .filter(Predicate.not(List::isEmpty))
+                    .map(l -> l.get(0));
         } catch (Exception e) {
             throw new CloudRuntimeException(String.format("Failed to fetch 
enforcement points due to %s", e.getMessage()));
         }
@@ -427,7 +450,15 @@ public class NsxApiClient {
     public TransportZoneListResult getTransportZones() {
         try {
             com.vmware.nsx.TransportZones transportZones = 
(com.vmware.nsx.TransportZones) 
nsxService.apply(com.vmware.nsx.TransportZones.class);
-            return transportZones.list(null, null, true, null, null, null, 
null, null, TransportType.OVERLAY.name(), null);
+            return PagedFetcher.<TransportZoneListResult, 
TransportZone>withPageFetcher(
+                    cursor -> transportZones.list(cursor, null, true, null, 
null, null, null, null, TransportType.OVERLAY.name(), null)
+                    ).cursorExtractor(TransportZoneListResult::getCursor)
+                    .itemsExtractor(TransportZoneListResult::getResults)
+                    .itemsSetter((page, allItems) -> {
+                        page.setResults(allItems);
+                        page.setResultCount((long) allItems.size());
+                    })
+                    .fetchAll();
         } catch (Exception e) {
             throw new CloudRuntimeException(String.format("Failed to fetch 
transport zones due to %s", e.getMessage()));
         }
@@ -468,7 +499,7 @@ public class NsxApiClient {
             removeSegment(segmentName, zoneId);
             DhcpRelayConfigs dhcpRelayConfig = (DhcpRelayConfigs) 
nsxService.apply(DhcpRelayConfigs.class);
             String dhcpRelayConfigId = 
NsxControllerUtils.getNsxDhcpRelayConfigId(zoneId, domainId, accountId, vpcId, 
networkId);
-            logger.debug(String.format("Removing the DHCP relay config with ID 
%s", dhcpRelayConfigId));
+            logger.debug("Removing the DHCP relay config with ID {}", 
dhcpRelayConfigId);
             dhcpRelayConfig.delete(dhcpRelayConfigId);
         } catch (Error error) {
             ApiError ae = error.getData()._convertTo(ApiError.class);
@@ -479,7 +510,7 @@ public class NsxApiClient {
     }
 
     protected void removeSegment(String segmentName, long zoneId) {
-        logger.debug(String.format("Removing the segment with ID %s", 
segmentName));
+        logger.debug("Removing the segment with ID {}", segmentName);
         Segments segmentService = (Segments) nsxService.apply(Segments.class);
         String errMsg = String.format("The segment with ID %s is not found, 
skipping removal", segmentName);
         try {
@@ -501,7 +532,7 @@ public class NsxApiClient {
             portCount = retrySegmentDeletion(segmentPortsService, segmentName, 
enforcementPointPath, zoneId);
         }
         if (portCount == 0L) {
-            logger.debug(String.format("Removing the segment with ID %s", 
segmentName));
+            logger.debug("Removing the segment with ID {}", segmentName);
             removeGroupForSegment(segmentName);
             segmentService.delete(segmentName);
         } else {
@@ -512,8 +543,18 @@ public class NsxApiClient {
     }
 
     private PolicyGroupMembersListResult getSegmentPortList(SegmentPorts 
segmentPortsService, String segmentName, String enforcementPointPath) {
-        return segmentPortsService.list(DEFAULT_DOMAIN, segmentName, null, 
enforcementPointPath,
-                false, null, 50L, false, null);
+        return PagedFetcher.
+                <PolicyGroupMembersListResult, 
PolicyGroupMemberDetails>withPageFetcher(
+                cursor -> segmentPortsService.list(DEFAULT_DOMAIN, 
segmentName, cursor, enforcementPointPath,
+                        false, null, 50L, false, null)
+                )
+                .cursorExtractor(PolicyGroupMembersListResult::getCursor)
+                .itemsExtractor(PolicyGroupMembersListResult::getResults)
+                .itemsSetter((page, allItems) -> {
+                    page.setResults(allItems);
+                    page.setResultCount((long) allItems.size());
+                })
+                .fetchAll();
     }
 
     private Long retrySegmentDeletion(SegmentPorts segmentPortsService, String 
segmentName, String enforcementPointPath, long zoneId) {
@@ -549,7 +590,7 @@ public class NsxApiClient {
                     .setEnabled(true)
                     .build();
 
-            logger.debug(String.format("Creating NSX static NAT rule %s for 
tier-1 gateway %s (VPC: %s)", ruleName, tier1GatewayName, vpcName));
+            logger.debug("Creating NSX static NAT rule {} for tier-1 gateway 
{} (VPC: {})", ruleName, tier1GatewayName, vpcName);
             natService.patch(tier1GatewayName, NatId.USER.name(), ruleName, 
rule);
         } catch (Error error) {
             ApiError ae = error.getData()._convertTo(ApiError.class);
@@ -585,8 +626,7 @@ public class NsxApiClient {
                 natService.delete(tier1GatewayName, NatId.USER.name(), 
ruleName);
             }
         } catch (Error error) {
-            String msg = String.format("Cannot find NAT rule with name %s: %s, 
skipping deletion", ruleName, error.getMessage());
-            logger.debug(msg);
+            logger.debug("Cannot find NAT rule with name {}: {}, skipping 
deletion", ruleName, error.getMessage());
         }
 
         if (service == Network.Service.PortForwarding) {
@@ -598,7 +638,7 @@ public class NsxApiClient {
                                          String vmIp, String publicPort, 
String service) {
         try {
             NatRules natService = (NatRules) nsxService.apply(NatRules.class);
-            logger.debug(String.format("Creating NSX Port-Forwarding NAT %s 
for network %s", ruleName, networkName));
+            logger.debug("Creating NSX Port-Forwarding NAT {} for network {}", 
ruleName, networkName);
             PolicyNatRule rule = new PolicyNatRule.Builder()
                     .setId(ruleName)
                     .setDisplayName(ruleName)
@@ -751,7 +791,15 @@ public class NsxApiClient {
     }
 
     LBMonitorProfileListResult listLBActiveMonitors(LbMonitorProfiles 
lbActiveMonitor) {
-        return lbActiveMonitor.list(null, false, null, null, null, null);
+        return PagedFetcher.<LBMonitorProfileListResult, 
Structure>withPageFetcher(
+                cursor -> lbActiveMonitor.list(cursor, false, null, null, 
null, null)
+                ).cursorExtractor(LBMonitorProfileListResult::getCursor)
+                .itemsExtractor(LBMonitorProfileListResult::getResults)
+                .itemsSetter((page, allItems) -> {
+                    page.setResults(allItems);
+                    page.setResultCount((long) allItems.size());
+                })
+                .fetchAll();
     }
 
     public void createNsxLoadBalancer(String tier1GatewayName) {
@@ -816,7 +864,7 @@ public class NsxApiClient {
                 return lbVirtualServer;
             }
         } catch (Exception e) {
-            logger.debug(String.format("Found an LB virtual server named: %s 
on NSX", lbVSName));
+            logger.debug("Found an LB virtual server named: {} on NSX", 
lbVSName);
             return null;
         }
         return null;
@@ -904,8 +952,15 @@ public class NsxApiClient {
     private String getLbProfileForProtocol(String protocol) {
         try {
             LbAppProfiles lbAppProfiles = (LbAppProfiles) 
nsxService.apply(LbAppProfiles.class);
-            LBAppProfileListResult lbAppProfileListResults = 
lbAppProfiles.list(null, null,
-                    null, null, null, null);
+            LBAppProfileListResult lbAppProfileListResults = 
PagedFetcher.<LBAppProfileListResult, Structure>withPageFetcher(
+                            cursor -> lbAppProfiles.list(cursor, null, null, 
null, null, null)
+                    ).cursorExtractor(LBAppProfileListResult::getCursor)
+                    .itemsExtractor(LBAppProfileListResult::getResults)
+                    .itemsSetter((page, allItems) -> {
+                        page.setResults(allItems);
+                        page.setResultCount((long) allItems.size());
+                    })
+                    .fetchAll();
             Optional<Structure> appProfile = 
lbAppProfileListResults.getResults().stream().filter(profile -> 
profile._getDataValue().getField("path").toString().contains(protocol.toLowerCase(Locale.ROOT))).findFirst();
             return appProfile.map(structure -> 
structure._getDataValue().getField("path").toString()).orElse(null);
         } catch (Error error) {
@@ -921,7 +976,15 @@ public class NsxApiClient {
             Services service = (Services) nsxService.apply(Services.class);
 
             // Find default service if present
-            ServiceListResult serviceList = service.list(null, true, false, 
null, null, null, null);
+            ServiceListResult serviceList = PagedFetcher.<ServiceListResult, 
com.vmware.nsx_policy.model.Service>withPageFetcher(
+                            cursor -> service.list(cursor, true, false, null, 
null, null, null)
+                    ).cursorExtractor(ServiceListResult::getCursor)
+                    .itemsExtractor(ServiceListResult::getResults)
+                    .itemsSetter((page, allItems) -> {
+                        page.setResults(allItems);
+                        page.setResultCount((long) allItems.size());
+                    })
+                    .fetchAll();
 
             List<com.vmware.nsx_policy.model.Service> services = 
serviceList.getResults();
             List<String> matchedDefaultSvc = 
services.parallelStream().filter(svc ->
@@ -1148,9 +1211,17 @@ public class NsxApiClient {
 
     private List<Group> listNsxGroups() {
         try {
-           Groups groups = (Groups) nsxService.apply(Groups.class);
-           GroupListResult result = groups.list(DEFAULT_DOMAIN, null, false, 
null, null, null, null, null);
-           return result.getResults();
+            Groups groups = (Groups) nsxService.apply(Groups.class);
+            GroupListResult result = PagedFetcher.<GroupListResult, 
Group>withPageFetcher(
+                            cursor -> groups.list(DEFAULT_DOMAIN, cursor, 
false, null, null, null, null, null)
+                    ).cursorExtractor(GroupListResult::getCursor)
+                    .itemsExtractor(GroupListResult::getResults)
+                    .itemsSetter((page, allItems) -> {
+                        page.setResults(allItems);
+                        page.setResultCount((long) allItems.size());
+                    })
+                    .fetchAll();
+            return result.getResults();
         } catch (Error error) {
             ApiError ae = error.getData()._convertTo(ApiError.class);
             String msg = String.format("Failed to list NSX groups, due to: 
%s", ae.getErrorMessage());
diff --git 
a/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/PagedFetcher.java
 
b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/PagedFetcher.java
new file mode 100644
index 00000000000..b3cd4e0a16f
--- /dev/null
+++ 
b/plugins/network-elements/nsx/src/main/java/org/apache/cloudstack/service/PagedFetcher.java
@@ -0,0 +1,82 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+package org.apache.cloudstack.service;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.function.BiConsumer;
+import java.util.function.Function;
+
+class PagedFetcher<R, T> {
+
+    private final Function<String, R> fetchPage;
+    private Function<R, String> cursorExtractor;
+    private Function<R, List<T>> itemsExtractor;
+    private BiConsumer<R, List<T>> itemsSetter;
+
+    static <R, T> PagedFetcher<R, T> withPageFetcher(Function<String, R> 
pageFetcher) {
+        return new PagedFetcher<>(pageFetcher);
+    }
+
+    PagedFetcher<R, T> cursorExtractor(Function<R, String> cursorProvider) {
+        this.cursorExtractor = cursorProvider;
+        return this;
+    }
+
+    PagedFetcher<R, T> itemsExtractor(Function<R, List<T>> resultsProvider) {
+        this.itemsExtractor = resultsProvider;
+        return this;
+    }
+
+    PagedFetcher<R, T> itemsSetter(BiConsumer<R, List<T>> resultsSetter) {
+        this.itemsSetter = resultsSetter;
+        return this;
+    }
+
+    private PagedFetcher(Function<String, R> pageFetcher) {
+        this.fetchPage = pageFetcher;
+    }
+
+    R fetchAll() {
+        Objects.requireNonNull(cursorExtractor, "Cursor extractor must be 
set");
+        Objects.requireNonNull(itemsExtractor, "Items extractor must be set");
+        Objects.requireNonNull(itemsSetter, "Items setter must be set");
+
+        R firstPage = fetchPage.apply(null);
+        String cursor = cursorExtractor.apply(firstPage);
+        if (cursor == null || cursor.isEmpty()) {
+            return firstPage;
+        }
+
+        List<T> firstResults = itemsExtractor.apply(firstPage);
+        List<T> allItems = firstResults != null
+                ? new ArrayList<>(firstResults)
+                : new ArrayList<>();
+        while (cursor != null && !cursor.isEmpty()) {
+            R nextPage = fetchPage.apply(cursor);
+            List<T> nextItems = itemsExtractor.apply(nextPage);
+            if (nextItems != null && !nextItems.isEmpty()) {
+                allItems.addAll(nextItems);
+            }
+            cursor = cursorExtractor.apply(nextPage);
+        }
+
+        itemsSetter.accept(firstPage, allItems);
+        return firstPage;
+    }
+}
diff --git 
a/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/PagedFetcherTest.java
 
b/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/PagedFetcherTest.java
new file mode 100644
index 00000000000..6d6b4cde132
--- /dev/null
+++ 
b/plugins/network-elements/nsx/src/test/java/org/apache/cloudstack/service/PagedFetcherTest.java
@@ -0,0 +1,156 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+package org.apache.cloudstack.service;
+
+import org.junit.Test;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+
+public class PagedFetcherTest {
+
+    private static class Page {
+        private String cursor;
+        private List<String> items;
+
+        Page(String cursor, List<String> items) {
+            this.cursor = cursor;
+            this.items = items;
+        }
+
+        String getCursor() {
+            return cursor;
+        }
+
+        List<String> getItems() {
+            return items;
+        }
+
+        void setItems(List<String> items) {
+            this.items = items;
+        }
+    }
+
+    @Test
+    public void testFetchAllWhenThereIsNoPagination() {
+        // given
+        Page firstPage = new Page(null, new ArrayList<>(List.of("a", "b")));
+        AtomicBoolean itemsSetterCalled = new AtomicBoolean(false);
+        PagedFetcher<Page, String> fetcher = PagedFetcher.<Page, 
String>withPageFetcher(
+                        cursor -> {
+                            assertNull(cursor);
+                            return firstPage;
+                        })
+                .cursorExtractor(Page::getCursor)
+                .itemsExtractor(Page::getItems)
+                .itemsSetter((page, items) -> itemsSetterCalled.set(true));
+
+        // when
+        Page result = fetcher.fetchAll();
+
+        // then
+        assertSame(firstPage, result);
+        assertEquals(List.of("a", "b"), result.getItems());
+        assertFalse("itemsSetter must not be called when there is no next 
page", itemsSetterCalled.get());
+    }
+
+    @Test
+    public void testFetchAllWhenThereIsNoPaginationAndEmptyCursor() {
+        // given
+        Page firstPage = new Page("", new ArrayList<>(List.of("x")));
+
+        AtomicBoolean itemsSetterCalled = new AtomicBoolean(false);
+
+        PagedFetcher<Page, String> fetcher = PagedFetcher
+                .<Page, String>withPageFetcher(cursor -> {
+                    assertNull(cursor);
+                    return firstPage;
+                })
+                .cursorExtractor(Page::getCursor)
+                .itemsExtractor(Page::getItems)
+                .itemsSetter((page, items) -> itemsSetterCalled.set(true));
+
+        // when
+        Page result = fetcher.fetchAll();
+
+        // then
+        assertSame(firstPage, result);
+        assertEquals(List.of("x"), result.getItems());
+        assertFalse("itemsSetter must not be called when there is no next 
page", itemsSetterCalled.get());
+    }
+
+    @Test
+    public void testFetchAllWhenMultiPages() {
+        // given
+        Page page1 = new Page("c1", new ArrayList<>(List.of("p1a", "p1b")));
+        Page page2 = new Page("c2", new ArrayList<>(List.of("p2a")));
+        Page page3 = new Page(null, new ArrayList<>(List.of("p3a", "p3b")));
+
+        Map<String, Page> pagesByCursor = new HashMap<>();
+        pagesByCursor.put(null, page1);
+        pagesByCursor.put("c1", page2);
+        pagesByCursor.put("c2", page3);
+
+        PagedFetcher<Page, String> fetcher = PagedFetcher
+                .<Page, String>withPageFetcher(pagesByCursor::get)
+                .cursorExtractor(Page::getCursor)
+                .itemsExtractor(Page::getItems)
+                .itemsSetter((page, items) -> {
+                    assertSame(page1, page);
+                    page.setItems(items);
+                });
+
+        // when
+        Page result = fetcher.fetchAll();
+
+        // then
+        assertSame("Result must be the first page object", page1, result);
+        assertEquals(List.of("p1a", "p1b", "p2a", "p3a", "p3b"), 
result.getItems());
+    }
+
+    @Test
+    public void testFetchAllFirstPageItemsNullSecondWithItems() {
+        // given
+        Page page1 = new Page("next", null);
+        Page page2 = new Page(null, new ArrayList<>(List.of("x", "y")));
+
+        Map<String, Page> pages = new HashMap<>();
+        pages.put(null, page1);
+        pages.put("next", page2);
+
+        PagedFetcher<Page, String> fetcher = PagedFetcher
+                .<Page, String>withPageFetcher(pages::get)
+                .cursorExtractor(Page::getCursor)
+                .itemsExtractor(Page::getItems)
+                .itemsSetter(Page::setItems);
+
+        // when
+        Page result = fetcher.fetchAll();
+
+        // then
+        assertSame(page1, result);
+        assertEquals(List.of("x", "y"), result.getItems());
+    }
+}

Reply via email to