NIFI-13030 Adding endpoint for comparing versions of registered flows

This closes #8670

Signed-off-by: Peter Gyori <pgyori@apache.org>
This commit is contained in:
Bence Simon 2024-04-19 11:16:26 +02:00 committed by Peter Gyori
parent 21f0ca47b0
commit 0a5be35357
12 changed files with 802 additions and 0 deletions

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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
*

View File

@ -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));

View File

@ -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
// --------------

View File

@ -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;
}
}

View File

@ -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);
}

View File

@ -0,0 +1,30 @@
/*
* 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 final class IntervalFactory {
private IntervalFactory() {
// Not to be instantiated
}
/**
* @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);
}
}

View File

@ -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;
}
}

View File

@ -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();

View File

@ -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)
);
}
}

View File

@ -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));
}
}