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

pgyori pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git


The following commit(s) were added to refs/heads/main by this push:
     new 0a5be35357 NIFI-13030 Adding endpoint for comparing versions of 
registered flows
0a5be35357 is described below

commit 0a5be3535711378caceb3fd529bf3ce86986da99
Author: Bence Simon <[email protected]>
AuthorDate: Fri Apr 19 11:16:26 2024 +0200

    NIFI-13030 Adding endpoint for comparing versions of registered flows
    
    This closes #8670
    
    Signed-off-by: Peter Gyori <[email protected]>
---
 .../nifi/registry/flow/FlowVersionLocation.java    |  17 +++
 .../org/apache/nifi/web/api/dto/DifferenceDTO.java |  14 ++
 .../org/apache/nifi/web/NiFiServiceFacade.java     |  11 ++
 .../apache/nifi/web/StandardNiFiServiceFacade.java |  36 +++++
 .../java/org/apache/nifi/web/api/FlowResource.java | 117 ++++++++++++++++
 .../apache/nifi/web/util/ClosedOpenInterval.java   |  82 +++++++++++
 .../java/org/apache/nifi/web/util/Interval.java    |  63 +++++++++
 .../org/apache/nifi/web/util/IntervalFactory.java} |  35 ++---
 .../org/apache/nifi/web/util/PaginationHelper.java |  99 +++++++++++++
 .../org/apache/nifi/web/api/TestFlowResource.java  | 156 +++++++++++++++++++++
 .../nifi/web/util/ClosedOpenIntervalTest.java      |  98 +++++++++++++
 .../apache/nifi/web/util/PaginationHelperTest.java |  79 +++++++++++
 12 files changed, 781 insertions(+), 26 deletions(-)

diff --git 
a/nifi-api/src/main/java/org/apache/nifi/registry/flow/FlowVersionLocation.java 
b/nifi-api/src/main/java/org/apache/nifi/registry/flow/FlowVersionLocation.java
index e615b6a45d..f93ef63649 100644
--- 
a/nifi-api/src/main/java/org/apache/nifi/registry/flow/FlowVersionLocation.java
+++ 
b/nifi-api/src/main/java/org/apache/nifi/registry/flow/FlowVersionLocation.java
@@ -19,6 +19,8 @@
 
 package org.apache.nifi.registry.flow;
 
+import java.util.Objects;
+
 /**
  * Information for locating a flow version in a flow registry.
  */
@@ -43,4 +45,19 @@ public class FlowVersionLocation extends FlowLocation {
         this.version = version;
     }
 
+    @Override
+    public boolean equals(final Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        final FlowVersionLocation that = (FlowVersionLocation) o;
+        return Objects.equals(getBranch(), that.getBranch())
+                && Objects.equals(getBucketId(), that.getBucketId())
+                && Objects.equals(getFlowId(), that.getFlowId())
+                && Objects.equals(version, that.version);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(getBranch(), getBucketId(), getFlowId(), version);
+    }
 }
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/dto/DifferenceDTO.java
 
b/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/dto/DifferenceDTO.java
index 0b1dd2a087..913084db52 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/dto/DifferenceDTO.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/dto/DifferenceDTO.java
@@ -21,6 +21,8 @@ import io.swagger.v3.oas.annotations.media.Schema;
 
 import jakarta.xml.bind.annotation.XmlType;
 
+import java.util.Objects;
+
 @XmlType(name = "difference")
 public class DifferenceDTO {
     private String differenceType;
@@ -44,4 +46,16 @@ public class DifferenceDTO {
         this.difference = difference;
     }
 
+    @Override
+    public boolean equals(final Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        final DifferenceDTO that = (DifferenceDTO) o;
+        return Objects.equals(differenceType, that.differenceType) && 
Objects.equals(difference, that.difference);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(differenceType, difference);
+    }
 }
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/NiFiServiceFacade.java
 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/NiFiServiceFacade.java
index b372d5cc83..ee6ea5982f 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/NiFiServiceFacade.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/NiFiServiceFacade.java
@@ -37,6 +37,7 @@ import org.apache.nifi.parameter.ParameterContext;
 import org.apache.nifi.parameter.ParameterGroupConfiguration;
 import org.apache.nifi.registry.flow.FlowLocation;
 import org.apache.nifi.registry.flow.FlowSnapshotContainer;
+import org.apache.nifi.registry.flow.FlowVersionLocation;
 import org.apache.nifi.registry.flow.RegisterAction;
 import org.apache.nifi.registry.flow.RegisteredFlow;
 import org.apache.nifi.registry.flow.RegisteredFlowSnapshot;
@@ -1501,6 +1502,16 @@ public interface NiFiServiceFacade {
      */
     RegisteredFlow deleteVersionedFlow(String registryId, String branch, 
String bucketId, String flowId);
 
+    /**
+     * Returns the differences of version B from version A.
+     *
+     * @param registryId the ID of the registry
+     * @param versionLocationA Location of the baseline snapshot of the 
comparison
+     * @param versionLocationB location of the compared snapshot
+     * @return the differences between the snapshots
+     */
+    FlowComparisonEntity getVersionDifference(String registryId, 
FlowVersionLocation versionLocationA, FlowVersionLocation versionLocationB);
+
     /**
      * Adds the given snapshot to the already existing Versioned Flow, which 
resides in the given Flow Registry with the given id
      *
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/StandardNiFiServiceFacade.java
 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/StandardNiFiServiceFacade.java
index dfa6f0df90..ce7c15ba70 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/StandardNiFiServiceFacade.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/StandardNiFiServiceFacade.java
@@ -5258,6 +5258,42 @@ public class StandardNiFiServiceFacade implements 
NiFiServiceFacade {
         }
     }
 
+    @Override
+    public FlowComparisonEntity getVersionDifference(final String registryId, 
FlowVersionLocation versionLocationA, FlowVersionLocation versionLocationB) {
+        final FlowComparisonEntity result = new FlowComparisonEntity();
+
+        if (versionLocationA.equals(versionLocationB)) {
+            // If both versions are the same, there is no need for comparison. 
Comparing them should have the same result but with the cost of some calls to 
the registry.
+            // Note: because of this optimization we return an empty non-error 
response in case of non-existing registry, bucket, flow or version if the 
versions are the same.
+            result.setComponentDifferences(Collections.emptySet());
+            return result;
+        }
+
+        final FlowSnapshotContainer snapshotA = this.getVersionedFlowSnapshot(
+                registryId, versionLocationA.getBranch(), 
versionLocationA.getBucketId(), versionLocationA.getFlowId(), 
versionLocationA.getVersion(), true);
+        final FlowSnapshotContainer snapshotB = this.getVersionedFlowSnapshot(
+                registryId, versionLocationB.getBranch(), 
versionLocationB.getBucketId(), versionLocationB.getFlowId(), 
versionLocationB.getVersion(), true);
+
+        final VersionedProcessGroup flowContentsA = 
snapshotA.getFlowSnapshot().getFlowContents();
+        final VersionedProcessGroup flowContentsB = 
snapshotB.getFlowSnapshot().getFlowContents();
+
+        final FlowComparator flowComparator = new StandardFlowComparator(
+                new StandardComparableDataFlow("Flow A", flowContentsA),
+                new StandardComparableDataFlow("Flow B", flowContentsB),
+                Collections.emptySet(), // Replacement of an external 
ControllerService is recognized as property change
+                new ConciseEvolvingDifferenceDescriptor(),
+                Function.identity(),
+                VersionedComponent::getIdentifier,
+                FlowComparatorVersionedStrategy.DEEP
+            );
+
+        final FlowComparison flowComparison = flowComparator.compare();
+        final Set<ComponentDifferenceDTO> differenceDtos = 
dtoFactory.createComponentDifferenceDtosForLocalModifications(flowComparison, 
flowContentsA, controllerFacade.getFlowManager());
+        result.setComponentDifferences(differenceDtos);
+
+        return result;
+    }
+
     @Override
     public boolean isAnyProcessGroupUnderVersionControl(final String groupId) {
         return 
isProcessGroupUnderVersionControl(processGroupDAO.getProcessGroup(groupId));
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/FlowResource.java
 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/FlowResource.java
index c62147a368..ed71ead166 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/FlowResource.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/FlowResource.java
@@ -49,6 +49,7 @@ import org.apache.nifi.flow.VersionedReportingTaskSnapshot;
 import org.apache.nifi.groups.ProcessGroup;
 import org.apache.nifi.nar.NarClassLoadersHolder;
 import org.apache.nifi.registry.client.NiFiRegistryException;
+import org.apache.nifi.registry.flow.FlowVersionLocation;
 import org.apache.nifi.util.NiFiProperties;
 import org.apache.nifi.web.IllegalClusterResourceRequestException;
 import org.apache.nifi.web.NiFiServiceFacade;
@@ -60,6 +61,8 @@ import org.apache.nifi.web.api.dto.BulletinBoardDTO;
 import org.apache.nifi.web.api.dto.BulletinQueryDTO;
 import org.apache.nifi.web.api.dto.ClusterDTO;
 import org.apache.nifi.web.api.dto.ClusterSummaryDTO;
+import org.apache.nifi.web.api.dto.ComponentDifferenceDTO;
+import org.apache.nifi.web.api.dto.DifferenceDTO;
 import org.apache.nifi.web.api.dto.NodeDTO;
 import org.apache.nifi.web.api.dto.ProcessGroupDTO;
 import org.apache.nifi.web.api.dto.RevisionDTO;
@@ -89,6 +92,7 @@ import org.apache.nifi.web.api.entity.CurrentUserEntity;
 import org.apache.nifi.web.api.entity.FlowAnalysisResultEntity;
 import org.apache.nifi.web.api.entity.FlowAnalysisRuleTypesEntity;
 import org.apache.nifi.web.api.entity.FlowBreadcrumbEntity;
+import org.apache.nifi.web.api.entity.FlowComparisonEntity;
 import org.apache.nifi.web.api.entity.FlowConfigurationEntity;
 import org.apache.nifi.web.api.entity.FlowRegistryBranchEntity;
 import org.apache.nifi.web.api.entity.FlowRegistryBranchesEntity;
@@ -145,6 +149,7 @@ import jakarta.ws.rs.core.HttpHeaders;
 import jakarta.ws.rs.core.MediaType;
 import jakarta.ws.rs.core.Response;
 import jakarta.ws.rs.core.StreamingOutput;
+import org.apache.nifi.web.util.PaginationHelper;
 
 import java.text.Collator;
 import java.time.OffsetDateTime;
@@ -2057,6 +2062,100 @@ public class FlowResource extends ApplicationResource {
         return generateOkResponse(flowDetails).build();
     }
 
+    @GET
+    @Consumes(MediaType.WILDCARD)
+    @Produces(MediaType.APPLICATION_JSON)
+    
@Path("registries/{registry-id}/branches/{branch-id-a}/buckets/{bucket-id-a}/flows/{flow-id-a}/{version-a}/diff/branches/{branch-id-b}/buckets/{bucket-id-b}/flows/{flow-id-b}/{version-b}")
+    @Operation(
+            summary = "Gets the differences between two versions of the same 
versioned flow, the basis of the comparison will be the first version",
+            responses = @ApiResponse(content = @Content(schema = 
@Schema(implementation = FlowComparisonEntity.class))),
+            security = {
+                    @SecurityRequirement(name = "Read - /flow")
+            }
+    )
+    @ApiResponses(
+            value = {
+                    @ApiResponse(responseCode = "400", description = "NiFi was 
unable to complete the request because it was invalid. The request should not 
be retried without modification."),
+                    @ApiResponse(responseCode = "401", description = "Client 
could not be authenticated."),
+                    @ApiResponse(responseCode = "403", description = "Client 
is not authorized to make this request."),
+                    @ApiResponse(responseCode = "404", description = "The 
specified resource could not be found."),
+                    @ApiResponse(responseCode = "409", description = "The 
request was valid but NiFi was not in the appropriate state to process it.")
+            }
+    )
+    public Response getVersionDifferences(
+            @Parameter(
+                    description = "The registry client id.",
+                    required = true
+            )
+            @PathParam("registry-id") String registryId,
+
+            @Parameter(
+                    description = "The branch id for the base version.",
+                    required = true
+            )
+            @PathParam("branch-id-a") String branchIdA,
+
+            @Parameter(
+                    description = "The bucket id for the base version.",
+                    required = true
+            )
+            @PathParam("bucket-id-a") String bucketIdA,
+
+            @Parameter(
+                    description = "The flow id for the base version.",
+                    required = true
+            )
+            @PathParam("flow-id-a") String flowIdA,
+
+            @Parameter(
+                    description = "The base version.",
+                    required = true
+            )
+            @PathParam("version-a") String versionA,
+
+            @Parameter(
+                    description = "The branch id for the compared version.",
+                    required = true
+            )
+            @PathParam("branch-id-b") String branchIdB,
+
+            @Parameter(
+                    description = "The bucket id for the compared version.",
+                    required = true
+            )
+            @PathParam("bucket-id-b") String bucketIdB,
+
+            @Parameter(
+                    description = "The flow id for the compared version.",
+                    required = true
+            )
+            @PathParam("flow-id-b") String flowIdB,
+
+            @Parameter(
+                    description = "The compared version.",
+                    required = true
+            )
+            @PathParam("version-b") String versionB,
+            @QueryParam("offset")
+            @Parameter(description = "Must be a non-negative number. Specifies 
the starting point of the listing. 0 means start from the beginning.")
+            @DefaultValue("0")
+            int offset,
+            @QueryParam("limit")
+            @Parameter(description = "Limits the number of differences listed. 
This might lead to partial result. 0 means no limitation is applied.")
+            @DefaultValue("1000")
+            int limit
+    ) {
+        authorizeFlow();
+        FlowVersionLocation baseVersionLocation = new 
FlowVersionLocation(branchIdA, bucketIdA, flowIdA, versionA);
+        FlowVersionLocation comparedVersionLocation = new 
FlowVersionLocation(branchIdB, bucketIdB, flowIdB, versionB);
+            final FlowComparisonEntity versionDifference = 
serviceFacade.getVersionDifference(registryId, baseVersionLocation, 
comparedVersionLocation);
+        // Note: with the current implementation, this is deterministic. 
However, the internal data structure used in comparison is set, thus
+        // later changes might cause discrepancies. Practical use of the 
endpoint usually remains within one "page" though.
+        return generateOkResponse(limitDifferences(versionDifference, offset, 
limit))
+                .type(MediaType.APPLICATION_JSON_TYPE)
+                .build();
+    }
+
     @GET
     @Consumes(MediaType.WILDCARD)
     @Produces(MediaType.APPLICATION_JSON)
@@ -2104,6 +2203,24 @@ public class FlowResource extends ApplicationResource {
         return 
generateOkResponse(versionedFlowSnapshotMetadataSetEntity).build();
     }
 
+    private static FlowComparisonEntity limitDifferences(final 
FlowComparisonEntity original, final int offset, final int limit) {
+        final List<ComponentDifferenceDTO> limited = 
PaginationHelper.paginateByContainedItems(
+                original.getComponentDifferences(), offset, limit, 
ComponentDifferenceDTO::getDifferences, FlowResource::limitDifferences);
+        final FlowComparisonEntity result = new FlowComparisonEntity();
+        result.setComponentDifferences(new HashSet<>(limited));
+        return result;
+    }
+
+    private static ComponentDifferenceDTO limitDifferences(final 
ComponentDifferenceDTO original, final List<DifferenceDTO> partial) {
+        final ComponentDifferenceDTO result = new ComponentDifferenceDTO();
+        result.setComponentType(original.getComponentType());
+        result.setComponentId(original.getComponentId());
+        result.setComponentName(original.getComponentName());
+        result.setProcessGroupId(original.getProcessGroupId());
+        result.setDifferences(partial);
+        return result;
+    }
+
     // --------------
     // bulletin board
     // --------------
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/util/ClosedOpenInterval.java
 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/util/ClosedOpenInterval.java
new file mode 100644
index 0000000000..224aea94a6
--- /dev/null
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/util/ClosedOpenInterval.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.nifi.web.util;
+
+/**
+ * This implementation includes the lower boundary but does not include the 
higher boundary.
+ */
+final class ClosedOpenInterval implements Interval {
+    private final int lowerBoundary;
+    private final int higherBoundary;
+
+    /**
+     * @param lowerBoundary Inclusive index of lower boundary
+     * @param higherBoundary Exclusive index of higher boundary. In case of 0, 
the higher boundary is unspecified and the interval is open.
+     */
+    ClosedOpenInterval(final int lowerBoundary, final int higherBoundary) {
+        if (lowerBoundary < 0) {
+            throw new IllegalArgumentException("Lower boundary cannot be 
negative");
+        }
+
+        if (higherBoundary < 0) {
+            throw new IllegalArgumentException("Higher boundary cannot be 
negative");
+        }
+
+        if (higherBoundary <= lowerBoundary && higherBoundary != 0) {
+            throw new IllegalArgumentException(
+                "Higher boundary cannot be lower than or equal to lower 
boundary except when unspecified. Higher boundary is considered unspecified 
when the value is set to 0"
+            );
+        }
+
+        this.lowerBoundary = lowerBoundary;
+        this.higherBoundary = higherBoundary;
+    }
+
+    @Override
+    public RelativePosition getRelativePositionOf(final int 
otherIntervalLowerBoundary, final int otherIntervalHigherBoundary) {
+        if (otherIntervalLowerBoundary < 0) {
+            throw new IllegalArgumentException("Lower boundary cannot be 
negative");
+        }
+
+        if (otherIntervalHigherBoundary <= 0) {
+            // Note: as a design decision the implementation currently does 
not support comparison with unspecified higher boundary
+            throw new IllegalArgumentException("Higher boundary must be 
positive");
+        }
+
+        if (otherIntervalLowerBoundary >= otherIntervalHigherBoundary) {
+            throw new IllegalArgumentException("Higher boundary must be 
greater than lower boundary");
+        }
+
+        if (otherIntervalHigherBoundary <= lowerBoundary) {
+            return RelativePosition.BEFORE;
+        } else if (otherIntervalLowerBoundary < lowerBoundary && 
otherIntervalHigherBoundary > higherBoundary && !this.isEndUnspecified()) {
+            return RelativePosition.EXCEEDS;
+        } else if (otherIntervalLowerBoundary < lowerBoundary) {
+            return RelativePosition.TAIL_INTERSECTS;
+        } else if (otherIntervalHigherBoundary <= higherBoundary || 
this.isEndUnspecified()) {
+            return RelativePosition.WITHIN;
+        } else if (otherIntervalLowerBoundary < higherBoundary) {
+            return RelativePosition.HEAD_INTERSECTS;
+        } else {
+            return RelativePosition.AFTER;
+        }
+    }
+
+    private boolean isEndUnspecified() {
+        return higherBoundary == 0;
+    }
+}
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/util/Interval.java
 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/util/Interval.java
new file mode 100644
index 0000000000..59a0c15909
--- /dev/null
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/util/Interval.java
@@ -0,0 +1,63 @@
+/*
+ * 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.nifi.web.util;
+
+public interface Interval {
+
+    enum RelativePosition {
+        /**
+         * The compared interval ends before the actual, there is no 
intersection.
+         */
+        BEFORE,
+
+        /**
+         * The compared interval exceeds the actual both at the low and high 
ends.
+         */
+        EXCEEDS,
+
+        /**
+         * The compared interval's tail (but not the whole interval) 
intersects the actual interval (part of it or the whole actual interval).
+         */
+        TAIL_INTERSECTS,
+
+        /**
+         * The compared interval is within the actual interval. It can match 
with the actual or contained by that.
+         */
+        WITHIN,
+
+        /**
+         *The compared interval's head (but not the whole interval) intersects 
the actual interval  (part of it or the whole actual interval).
+         */
+        HEAD_INTERSECTS,
+
+        /**
+         * The compared interval starts after the actual, there is no 
intersection.
+         */
+        AFTER,
+    }
+
+    /**
+     * Relative position of the "other" interval compared to this.
+     *
+     * @param otherIntervalLowerBoundary Lower boundary of the compared 
interval.
+     * @param otherIntervalHigherBoundary Higher boundary of the compared 
interval.
+     *
+     * @return Returns the relative position of the "other" interval compared 
to this interval. For example: if the result
+     *         is BEFORE, read it as: the other interval ends BEFORE the 
actual (and there is no intersection between them).
+     */
+    RelativePosition getRelativePositionOf(final int 
otherIntervalLowerBoundary, final int otherIntervalHigherBoundary);
+}
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/dto/DifferenceDTO.java
 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/util/IntervalFactory.java
similarity index 53%
copy from 
nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/dto/DifferenceDTO.java
copy to 
nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/util/IntervalFactory.java
index 0b1dd2a087..9d26eae1ec 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/dto/DifferenceDTO.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/util/IntervalFactory.java
@@ -14,34 +14,17 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+package org.apache.nifi.web.util;
 
-package org.apache.nifi.web.api.dto;
-
-import io.swagger.v3.oas.annotations.media.Schema;
-
-import jakarta.xml.bind.annotation.XmlType;
-
-@XmlType(name = "difference")
-public class DifferenceDTO {
-    private String differenceType;
-    private String difference;
-
-    @Schema(description = "The type of difference")
-    public String getDifferenceType() {
-        return differenceType;
+public final class IntervalFactory {
+    private IntervalFactory() {
+        // Not to be instantiated
     }
 
-    public void setDifferenceType(String differenceType) {
-        this.differenceType = differenceType;
+    /**
+     * @return Returns an interval instance with closed low and open high 
boundary.
+     */
+    static Interval getClosedOpenInterval(final int lowerBoundary, final int 
higherBoundary) {
+        return new ClosedOpenInterval(lowerBoundary, higherBoundary);
     }
-
-    @Schema(description = "Description of the difference")
-    public String getDifference() {
-        return difference;
-    }
-
-    public void setDifference(String difference) {
-        this.difference = difference;
-    }
-
 }
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/util/PaginationHelper.java
 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/util/PaginationHelper.java
new file mode 100644
index 0000000000..04c90cfbd1
--- /dev/null
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/util/PaginationHelper.java
@@ -0,0 +1,99 @@
+/*
+ * 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.nifi.web.util;
+
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Objects;
+import java.util.function.BiFunction;
+import java.util.function.Function;
+
+public class PaginationHelper {
+    public static <T, E> List<T> paginateByContainedItems(
+            final Iterable<T> original,
+            final int offset,
+            final int limit,
+            final Function<T, List<E>> getContainedItems,
+            final BiFunction<T, List<E>, T> createPartialItem
+    ) {
+        Objects.requireNonNull(original);
+        Objects.requireNonNull(getContainedItems);
+        Objects.requireNonNull(createPartialItem);
+
+        if (offset < 0) {
+            throw new IllegalArgumentException("Offset cannot be negative");
+        }
+
+        if (limit < 0) {
+            throw new IllegalArgumentException("Limit cannot be negative");
+        }
+
+        final List<T> result = new LinkedList<>();
+        final int higherBoundary = limit == 0 ? 0 : offset + limit;
+        final Interval interval = 
IntervalFactory.getClosedOpenInterval(offset, higherBoundary);
+        int pointer = 0;
+
+        if (offset == 0 && limit == 0) {
+            original.forEach(result::add);
+            return result;
+        }
+
+        for (final T candidate : original) {
+            final List<E> containedItems = getContainedItems.apply(candidate);
+            final ClosedOpenInterval.RelativePosition position = 
interval.getRelativePositionOf(pointer, pointer + containedItems.size());
+
+            switch (position) {
+                case BEFORE: {
+                    pointer += containedItems.size();
+                    break;
+                }
+                case EXCEEDS: {
+                    final int startingPoint = offset - pointer;
+                    final List<E> partialItems = 
containedItems.subList(startingPoint, limit + 1);
+                    final T partial = createPartialItem.apply(candidate, 
partialItems);
+                    result.add(partial);
+                    pointer += startingPoint + partialItems.size();
+                    break;
+                }
+                case TAIL_INTERSECTS: {
+                    final List<E> partialItems = containedItems.subList(offset 
- pointer, containedItems.size());
+                    final T partial = createPartialItem.apply(candidate, 
partialItems);
+                    result.add(partial);
+                    pointer += containedItems.size();
+                    break;
+                }
+                case WITHIN: {
+                    result.add(candidate);
+                    pointer += containedItems.size();
+                    break;
+                }
+                case HEAD_INTERSECTS: {
+                    final List<E> partialItems = containedItems.subList(0, 
limit + offset - pointer);
+                    final T partial = createPartialItem.apply(candidate, 
partialItems);
+                    result.add(partial);
+                    pointer += partialItems.size();
+                    break;
+                }
+                case AFTER:
+                default:
+                    // Do nothing
+            }
+        }
+
+        return result;
+    }
+}
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/TestFlowResource.java
 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/TestFlowResource.java
index 58fa0395e4..8635b709bf 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/TestFlowResource.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/TestFlowResource.java
@@ -33,8 +33,12 @@ import 
org.apache.nifi.prometheus.util.ConnectionAnalyticsMetricsRegistry;
 import org.apache.nifi.prometheus.util.JvmMetricsRegistry;
 import org.apache.nifi.prometheus.util.NiFiMetricsRegistry;
 import org.apache.nifi.prometheus.util.PrometheusMetricsUtil;
+import org.apache.nifi.registry.flow.FlowVersionLocation;
 import org.apache.nifi.web.NiFiServiceFacade;
 import org.apache.nifi.web.ResourceNotFoundException;
+import org.apache.nifi.web.api.dto.ComponentDifferenceDTO;
+import org.apache.nifi.web.api.dto.DifferenceDTO;
+import org.apache.nifi.web.api.entity.FlowComparisonEntity;
 import org.apache.nifi.web.api.request.FlowMetricsProducer;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
@@ -50,10 +54,13 @@ import java.io.IOException;
 import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import java.util.stream.Collectors;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -62,6 +69,9 @@ import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.anySet;
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.anyString;
+import static org.mockito.Mockito.doReturn;
 import static org.mockito.Mockito.when;
 
 @ExtendWith(MockitoExtension.class)
@@ -82,6 +92,13 @@ public class TestFlowResource {
     private static final int COMPONENT_TYPE_VALUE_INDEX = 1;
     private static final String CLUSTER_TYPE_LABEL = "cluster";
     private static final String CLUSTER_LABEL_KEY = "instance";
+    private static final String SAMPLE_REGISTRY_ID = 
"0e87642a-7720-4799-a3bd-04db74b86e85";
+    private static final String SAMPLE_BRANCH_ID_A = 
"c302f541-976e-4c51-952d-345516444e3d";
+    private static final String SAMPLE_BUCKET_ID_A = 
"23da421d-a8da-4fa3-939e-658d8f35b972";
+    private static final String SAMPLE_FLOW_ID_A = 
"34e4c8c5-f61d-45a4-8035-2aa3641ae904";
+    private static final String SAMPLE_BRANCH_ID_B = 
"fae2ef59-eb0d-4de6-ae31-342089fd229f";
+    private static final String SAMPLE_BUCKET_ID_B = 
"42998285-d06c-41dd-a757-7a14ab9673f4";
+    private static final String SAMPLE_FLOW_ID_B = 
"e6483662-9226-41c1-adec-10357af97ce2";
 
     @InjectMocks
     private FlowResource resource = new FlowResource();
@@ -282,6 +299,145 @@ public class TestFlowResource {
         assertEquals(2L, result.get(SAMPLE_LABEL_VALUES_ROOT_PROCESS_GROUP));
     }
 
+    @Test
+    public void testGetVersionDifferencesWithoutLimitations() {
+        setUpGetVersionDifference();
+
+        final Response response = resource.getVersionDifferences(
+                SAMPLE_REGISTRY_ID, SAMPLE_BRANCH_ID_A, SAMPLE_BUCKET_ID_A, 
SAMPLE_FLOW_ID_A, "1", SAMPLE_BRANCH_ID_B, SAMPLE_BUCKET_ID_B, 
SAMPLE_FLOW_ID_B, "2", 0, 0);
+        assertNotNull(response);
+        assertEquals(MediaType.valueOf(MediaType.APPLICATION_JSON), 
response.getMediaType());
+        
assertTrue(FlowComparisonEntity.class.isInstance(response.getEntity()));
+
+        final FlowComparisonEntity entity = (FlowComparisonEntity) 
response.getEntity();
+        final List<DifferenceDTO> differences = 
entity.getComponentDifferences().stream().map(ComponentDifferenceDTO::getDifferences).flatMap(Collection::stream).collect(Collectors.toList());
+        assertEquals(5, differences.size());
+    }
+
+    @Test
+    public void testGetVersionDifferencesFromBeginningWithPartialResults() {
+        setUpGetVersionDifference();
+
+        final Response response = resource.getVersionDifferences(
+            SAMPLE_REGISTRY_ID, SAMPLE_BRANCH_ID_A, SAMPLE_BUCKET_ID_A, 
SAMPLE_FLOW_ID_A, "1", SAMPLE_BRANCH_ID_B, SAMPLE_BUCKET_ID_B, 
SAMPLE_FLOW_ID_B, "2", 0, 2
+        );
+
+        assertNotNull(response);
+        assertEquals(MediaType.valueOf(MediaType.APPLICATION_JSON), 
response.getMediaType());
+        
assertTrue(FlowComparisonEntity.class.isInstance(response.getEntity()));
+
+        final FlowComparisonEntity entity = (FlowComparisonEntity) 
response.getEntity();
+        final List<DifferenceDTO> differences = 
entity.getComponentDifferences().stream().map(ComponentDifferenceDTO::getDifferences).flatMap(Collection::stream).collect(Collectors.toList());
+        assertEquals(2, differences.size());
+        assertEquals(createDifference("Component Added", "Connection was 
added"), differences.get(0));
+        assertEquals(createDifference("Property Value Changed", "From '0B' to 
'1KB'"), differences.get(1));
+    }
+
+    @Test
+    public void 
testGetVersionDifferencesFromBeginningExtendedWithPartialResults() {
+        setUpGetVersionDifference();
+
+        final Response response = resource.getVersionDifferences(
+            SAMPLE_REGISTRY_ID, SAMPLE_BRANCH_ID_A, SAMPLE_BUCKET_ID_A, 
SAMPLE_FLOW_ID_A, "1", SAMPLE_BRANCH_ID_B, SAMPLE_BUCKET_ID_B, 
SAMPLE_FLOW_ID_B, "2", 0, 3
+        );
+
+        assertNotNull(response);
+        assertEquals(MediaType.valueOf(MediaType.APPLICATION_JSON), 
response.getMediaType());
+        
assertTrue(FlowComparisonEntity.class.isInstance(response.getEntity()));
+
+        final FlowComparisonEntity entity = (FlowComparisonEntity) 
response.getEntity();
+        final List<DifferenceDTO> differences = 
entity.getComponentDifferences().stream().map(ComponentDifferenceDTO::getDifferences).flatMap(Collection::stream).collect(Collectors.toList());
+        assertEquals(3, differences.size());
+        assertEquals(createDifference("Component Added", "Connection was 
added"), differences.get(0));
+        assertEquals(createDifference("Property Value Changed", "From '0B' to 
'1KB'"), differences.get(1));
+        assertEquals(createDifference("Position Changed", "Position was 
changed"), differences.get(2));
+    }
+
+    @Test
+    public void testGetVersionDifferencesWithOffsetAndPartialResults() {
+        setUpGetVersionDifference();
+
+        final Response response = resource.getVersionDifferences(
+            SAMPLE_REGISTRY_ID, SAMPLE_BRANCH_ID_A, SAMPLE_BUCKET_ID_A, 
SAMPLE_FLOW_ID_A, "1", SAMPLE_BRANCH_ID_B, SAMPLE_BUCKET_ID_B, 
SAMPLE_FLOW_ID_B, "2", 2, 3
+        );
+
+        assertNotNull(response);
+        assertEquals(MediaType.valueOf(MediaType.APPLICATION_JSON), 
response.getMediaType());
+        
assertTrue(FlowComparisonEntity.class.isInstance(response.getEntity()));
+
+        final FlowComparisonEntity entity = (FlowComparisonEntity) 
response.getEntity();
+        final List<DifferenceDTO> differences = 
entity.getComponentDifferences().stream().map(ComponentDifferenceDTO::getDifferences).flatMap(Collection::stream).collect(Collectors.toList());
+        assertEquals(3, differences.size());
+        assertEquals(createDifference("Position Changed", "Position was 
changed"), differences.get(0));
+        assertEquals(createDifference("Property Value Changed", "From 'false' 
to 'true'"), differences.get(1));
+        assertEquals(createDifference("Component Added", "Processor was 
added"), differences.get(2));
+    }
+
+    @Test
+    public void testGetVersionDifferencesWithOffsetAndOnlyPartialResult() {
+        setUpGetVersionDifference();
+
+        final Response response = resource.getVersionDifferences(
+            SAMPLE_REGISTRY_ID, SAMPLE_BRANCH_ID_A, SAMPLE_BUCKET_ID_A, 
SAMPLE_FLOW_ID_A, "1", SAMPLE_BRANCH_ID_B, SAMPLE_BUCKET_ID_B, 
SAMPLE_FLOW_ID_B, "2", 2, 1
+        );
+
+        assertNotNull(response);
+        assertEquals(MediaType.valueOf(MediaType.APPLICATION_JSON), 
response.getMediaType());
+        
assertTrue(FlowComparisonEntity.class.isInstance(response.getEntity()));
+
+        final FlowComparisonEntity entity = (FlowComparisonEntity) 
response.getEntity();
+        final List<DifferenceDTO> differences = 
entity.getComponentDifferences().stream().map(ComponentDifferenceDTO::getDifferences).flatMap(Collection::stream).collect(Collectors.toList());
+        assertEquals(1, differences.size());
+        assertEquals(createDifference("Position Changed", "Position was 
changed"), differences.get(0));
+    }
+
+    private void setUpGetVersionDifference() {
+        final FlowVersionLocation baseLocation = new 
FlowVersionLocation(SAMPLE_BRANCH_ID_A, SAMPLE_BUCKET_ID_A, SAMPLE_FLOW_ID_A, 
"1");
+        final FlowVersionLocation comparedLocation = new 
FlowVersionLocation(SAMPLE_BRANCH_ID_B, SAMPLE_BUCKET_ID_B, SAMPLE_FLOW_ID_B, 
"2");
+        
doReturn(getDifferences()).when(serviceFacade).getVersionDifference(anyString(),
 any(FlowVersionLocation.class), any(FlowVersionLocation.class));
+    }
+
+    private static DifferenceDTO createDifference(final String type, final 
String difference) {
+        final DifferenceDTO result = new DifferenceDTO();
+        result.setDifferenceType(type);
+        result.setDifference(difference);
+        return result;
+    }
+
+    private static FlowComparisonEntity getDifferences() {
+        final FlowComparisonEntity differences = new FlowComparisonEntity();
+        final Set<ComponentDifferenceDTO> componentDifferences = new 
HashSet<>();
+
+        final ComponentDifferenceDTO changedComponent1 = new 
ComponentDifferenceDTO();
+        
changedComponent1.setComponentId("d72f9efe-506d-30e8-8a9f-257a69e73cd2");
+        changedComponent1.setComponentName("LogAttribute");
+        changedComponent1.setComponentType("Processor");
+        changedComponent1.setDifferences(List.of(createDifference("Component 
Added", "Processor was added")));
+
+        final ComponentDifferenceDTO changedComponent2 = new 
ComponentDifferenceDTO();
+        
changedComponent2.setComponentId("46aa1d19-65ee-32f5-83dc-e14a8d3f7e7f");
+        changedComponent2.setComponentName("GenerateFlowFile");
+        changedComponent2.setComponentType("Processor");
+        changedComponent2.setDifferences(List.of(
+            createDifference("Property Value Changed", "From '0B' to '1KB'"),
+            createDifference("Position Changed", "Position was changed"),
+            createDifference("Property Value Changed", "From 'false' to 
'true'")
+        ));
+
+        final ComponentDifferenceDTO changedComponent3 = new 
ComponentDifferenceDTO();
+        
changedComponent3.setComponentId("cfd8f7ec-3f40-3763-af15-2dc0e227ed61");
+        changedComponent3.setComponentName("");
+        changedComponent3.setComponentType("Connection");
+        changedComponent3.setDifferences(List.of(createDifference("Component 
Added", "Connection was added")));
+
+        componentDifferences.add(changedComponent1);
+        componentDifferences.add(changedComponent2);
+        componentDifferences.add(changedComponent3);
+        differences.setComponentDifferences(componentDifferences);
+
+        return differences;
+    }
+
     private String getResponseOutput(final Response response) throws 
IOException {
         final StreamingOutput streamingOutput = (StreamingOutput) 
response.getEntity();
         final ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/util/ClosedOpenIntervalTest.java
 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/util/ClosedOpenIntervalTest.java
new file mode 100644
index 0000000000..a38475a011
--- /dev/null
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/util/ClosedOpenIntervalTest.java
@@ -0,0 +1,98 @@
+/*
+ * 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.nifi.web.util;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.util.stream.Stream;
+
+class ClosedOpenIntervalTest {
+
+    @Test
+    public void testNegativeLowerBoundary() {
+        Assertions.assertThrows(IllegalArgumentException.class, () -> new 
ClosedOpenInterval(-1, 3));
+    }
+
+    @Test
+    public void testNegativeHigherBoundary() {
+        Assertions.assertThrows(IllegalArgumentException.class, () -> new 
ClosedOpenInterval(0, -1));
+    }
+
+    @Test
+    public void testSwitchedBoundaries() {
+        Assertions.assertThrows(IllegalArgumentException.class, () -> new 
ClosedOpenInterval(7, 3));
+    }
+
+    @Test
+    public void testCompareWhenOtherLowerBoundaryIsNegative() {
+        final ClosedOpenInterval testSubject = new ClosedOpenInterval(1, 3);
+        Assertions.assertThrows(IllegalArgumentException.class, () -> 
testSubject.getRelativePositionOf(-1, 4));
+    }
+
+    @Test
+    public void testCompareWhenOtherBoundariesAreSwitched() {
+        final ClosedOpenInterval testSubject = new ClosedOpenInterval(1, 3);
+        Assertions.assertThrows(IllegalArgumentException.class, () -> 
testSubject.getRelativePositionOf(9, 4));
+    }
+
+    @Test
+    public void testCompareWhenOtherHigherBoundaryIsUnspecified() {
+        final ClosedOpenInterval testSubject = new ClosedOpenInterval(1, 3);
+        Assertions.assertThrows(IllegalArgumentException.class, () -> 
testSubject.getRelativePositionOf(2, 0));
+    }
+
+    @Test
+    public void testZeroElementInterval() {
+        Assertions.assertThrows(IllegalArgumentException.class, () -> new 
ClosedOpenInterval(3, 3));
+    }
+
+    @ParameterizedTest(name = "{0}")
+    @MethodSource("givenTestData")
+    public void testCheckForOverlapping(
+        final String name, final ClosedOpenInterval interval, final int 
otherIntervalLowerBoundary, final int otherIntervalHigherBoundary, final 
ClosedOpenInterval.RelativePosition expectedResult
+    ) {
+        Assertions.assertEquals(expectedResult, 
interval.getRelativePositionOf(otherIntervalLowerBoundary, 
otherIntervalHigherBoundary));
+    }
+
+    private static Stream<Arguments> givenTestData() {
+        return Stream.of(
+                //  Both boundaries are defined
+                Arguments.of("Other starts after actual ends", new 
ClosedOpenInterval(7,  10), 11, 13, ClosedOpenInterval.RelativePosition.AFTER),
+                Arguments.of("Other starts where actual ends", new 
ClosedOpenInterval(7, 10), 10, 13, ClosedOpenInterval.RelativePosition.AFTER),
+                Arguments.of("Other starts within actual and ends after 
actual", new ClosedOpenInterval(7, 10), 9, 13, 
ClosedOpenInterval.RelativePosition.HEAD_INTERSECTS),
+                Arguments.of("Other starts within actual and ends where actual 
ends", new ClosedOpenInterval(7, 10), 8, 10, 
ClosedOpenInterval.RelativePosition.WITHIN),
+                Arguments.of("Other is contained by actual", new 
ClosedOpenInterval(7, 10), 8, 9, ClosedOpenInterval.RelativePosition.WITHIN),
+                Arguments.of("Other starts where actual and ends within 
actual", new ClosedOpenInterval(7, 10), 7, 12, 
ClosedOpenInterval.RelativePosition.HEAD_INTERSECTS),
+                Arguments.of("Other matches actual", new ClosedOpenInterval(7, 
10), 7, 10, ClosedOpenInterval.RelativePosition.WITHIN),
+                Arguments.of("Other starts where actual and finishes within", 
new ClosedOpenInterval(7, 10), 7, 9, 
ClosedOpenInterval.RelativePosition.WITHIN),
+                Arguments.of("Other exceeds actual in both directions", new 
ClosedOpenInterval(7, 10), 6, 12, ClosedOpenInterval.RelativePosition.EXCEEDS),
+                Arguments.of("Other starts before actual and ends where actual 
ends", new ClosedOpenInterval(7, 10), 5, 10, 
ClosedOpenInterval.RelativePosition.TAIL_INTERSECTS),
+                Arguments.of("Other starts before actual and ends within", new 
ClosedOpenInterval(7, 10), 5, 9, 
ClosedOpenInterval.RelativePosition.TAIL_INTERSECTS),
+                Arguments.of("Other starts where actual ends", new 
ClosedOpenInterval(7, 10), 2, 7, ClosedOpenInterval.RelativePosition.BEFORE),
+                Arguments.of("Other precedes actual interval", new 
ClosedOpenInterval(7, 10), 2, 6, ClosedOpenInterval.RelativePosition.BEFORE),
+
+                // Actual has no higher boundary defined
+                Arguments.of("Fully within with no higher boundary, when start 
at lower boundary", new ClosedOpenInterval(3, 0), 3, 6, 
ClosedOpenInterval.RelativePosition.WITHIN),
+                Arguments.of("Fully within with no higher boundary", new 
ClosedOpenInterval(3, 0), 11, 12, ClosedOpenInterval.RelativePosition.WITHIN),
+                Arguments.of("Tail is within with no higher boundary", new 
ClosedOpenInterval(3, 0), 2, 4, 
ClosedOpenInterval.RelativePosition.TAIL_INTERSECTS)
+        );
+    }
+}
\ No newline at end of file
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/util/PaginationHelperTest.java
 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/util/PaginationHelperTest.java
new file mode 100644
index 0000000000..669f9105b9
--- /dev/null
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/util/PaginationHelperTest.java
@@ -0,0 +1,79 @@
+/*
+ * 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.nifi.web.util;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+class PaginationHelperTest {
+
+    @Test
+    public void testCreatingWithNegativeOffset() {
+        Assertions.assertThrows(IllegalArgumentException.class, () -> 
PaginationHelper.paginateByContainedItems(getTestInput(), -1, 3, 
Function.identity(), (original, partialList) -> partialList));
+    }
+
+    @Test
+    public void testCreatingWithNegativeLimit() {
+        Assertions.assertThrows(IllegalArgumentException.class, () -> 
PaginationHelper.paginateByContainedItems(getTestInput(), 0, -1, 
Function.identity(), (original, partialList) -> partialList));
+    }
+
+    @ParameterizedTest(name = "{0}")
+    @MethodSource("givenTestData")
+    public void testPaginateByContainedItems(final String name, final int 
offset, final int limit, final List<Integer> expectedResult) {
+        final List<List<Integer>> result = 
PaginationHelper.paginateByContainedItems(getTestInput(), offset, limit, 
Function.identity(), (original, partialList) -> partialList);
+        final List<Integer> flatten = 
result.stream().flatMap(Collection::stream).collect(Collectors.toList());
+        Assertions.assertIterableEquals(expectedResult, flatten);
+    }
+
+    private static Stream<Arguments> givenTestData() {
+        return Stream.of(
+                Arguments.of("Full result set", 0, 0, Arrays.asList(1, 2, 3, 
4, 5, 6, 7, 8, 9, 10, 11, 12)),
+                Arguments.of("Offset only when starts with full", 3, 0, 
Arrays.asList(4, 5, 6, 7, 8, 9, 10, 11, 12)),
+                Arguments.of("Offset only when starts with partial", 4, 0, 
Arrays.asList(5, 6, 7, 8, 9, 10, 11, 12)),
+                Arguments.of("From beginning with partial", 0, 5, 
Arrays.asList(1, 2, 3, 4, 5)),
+                Arguments.of("Beginning with partial and offset", 1, 5, 
Arrays.asList(2, 3, 4, 5, 6)),
+                Arguments.of("Middle partial only", 4, 2, Arrays.asList(5, 6)),
+                Arguments.of("From the end with partial", 9, 3, 
Arrays.asList(10, 11, 12)),
+                Arguments.of("From the end with partial when spills over", 9, 
5, Arrays.asList(10, 11, 12)),
+                Arguments.of("Clear cut", 3, 5, Arrays.asList(4, 5, 6, 7, 8)),
+                Arguments.of("Long result", 2, 8, Arrays.asList(3, 4, 5, 6, 7, 
8, 9, 10)),
+                Arguments.of("All the original items", 0, 12, Arrays.asList(1, 
2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12)),
+                Arguments.of("Second partial", 5, 7, Arrays.asList(6, 7, 8, 9, 
10, 11, 12)),
+                Arguments.of("From the end of the range", 12, 3, 
Collections.emptyList()),
+                Arguments.of("Outside of the range", 14, 4, 
Collections.emptyList())
+        );
+    }
+
+    private static List<List<Integer>> getTestInput() {
+        final List<Integer> l1 = Arrays.asList(1, 2, 3);
+        final List<Integer> l2 = Arrays.asList(4, 5, 6, 7, 8);
+        final List<Integer> l3 = Arrays.asList(9, 10, 11, 12);
+        return new ArrayList<>(Arrays.asList(l1, l2, l3));
+    }
+}
\ No newline at end of file

Reply via email to