From: Arpit Singh Date: Fri, 28 Mar 2025 13:24:30 +0000 (+0530) Subject: REST operations using Delta Report X-Git-Url: https://gerrit.onap.org/r/gitweb?a=commitdiff_plain;h=86a9a07c9e1ef20e9a4191abcc20837eaf0a42e3;p=cps.git REST operations using Delta Report - Feature that uses the delta report to perform REST operations. - The code uses underlying CPS data java interface logic to perform the REST operations. - The REST operations are executed based on the 'action' identified in the delta report. - The supported operations are: create, delete and update. - Fixed indentation error in CpsDataServiceImpl Issue-ID: CPS-2614 Change-Id: Ic1c9f5d2320afd8e4fb49b638e6636291124ea75 Signed-off-by: Arpit Singh --- diff --git a/cps-rest/docs/openapi/cpsDelta.yml b/cps-rest/docs/openapi/cpsDelta.yml index 14655ea036..4d7781c047 100644 --- a/cps-rest/docs/openapi/cpsDelta.yml +++ b/cps-rest/docs/openapi/cpsDelta.yml @@ -94,3 +94,32 @@ delta: $ref: 'components.yml#/components/responses/Forbidden' '500': $ref: 'components.yml#/components/responses/InternalServerError' + +applyChangesInDeltaReport: + post: + description: Use the delta report to perform batch operations on an anchor in a dataspace. + tags: + - cps-delta + summary: Apply delta to an anchor + operationId: applyChangesInDeltaReport + parameters: + - $ref: 'components.yml#/components/parameters/dataspaceNameInPath' + - $ref: 'components.yml#/components/parameters/sourceAnchorNameInPath' + requestBody: + required: true + content: + application/json: + schema: + type: string + examples: + dataSample: + $ref: 'components.yml#/components/examples/deltaReportSample' + responses: + '201': + $ref: 'components.yml#/components/responses/Created' + '400': + $ref: 'components.yml#/components/responses/BadRequest' + '403': + $ref: 'components.yml#/components/responses/Forbidden' + '500': + $ref: 'components.yml#/components/responses/InternalServerError' diff --git a/cps-rest/docs/openapi/openapi.yml b/cps-rest/docs/openapi/openapi.yml index a141032c3f..615172be10 100644 --- a/cps-rest/docs/openapi/openapi.yml +++ b/cps-rest/docs/openapi/openapi.yml @@ -106,6 +106,9 @@ paths: /v2/dataspaces/{dataspace-name}/anchors/{source-anchor-name}/delta: $ref: 'cpsDelta.yml#/delta' + /v2/dataspaces/{dataspace-name}/anchors/{source-anchor-name}/applyChangesInDeltaReport: + $ref: 'cpsDelta.yml#/applyChangesInDeltaReport' + /v1/dataspaces/{dataspace-name}/anchors/{anchor-name}/nodes/query: $ref: 'cpsQueryV1Deprecated.yml#/nodesByDataspaceAndAnchorAndCpsPath' diff --git a/cps-rest/src/main/java/org/onap/cps/rest/controller/DeltaRestController.java b/cps-rest/src/main/java/org/onap/cps/rest/controller/DeltaRestController.java index c4631c7703..824dad9eb4 100644 --- a/cps-rest/src/main/java/org/onap/cps/rest/controller/DeltaRestController.java +++ b/cps-rest/src/main/java/org/onap/cps/rest/controller/DeltaRestController.java @@ -48,7 +48,6 @@ public class DeltaRestController implements CpsDeltaApi { private final CpsDeltaService cpsDeltaService; private final JsonObjectMapper jsonObjectMapper; - @Timed(value = "cps.delta.controller.get.delta", description = "Time taken to get delta between anchors") @Override @@ -88,4 +87,12 @@ public class DeltaRestController implements CpsDeltaApi { xpath, yangResourceMap, targetData, fetchDescendantsOption, groupDataNodes)); return new ResponseEntity<>(jsonObjectMapper.asJsonString(deltaReports), HttpStatus.OK); } + + public ResponseEntity applyChangesInDeltaReport(final String dataspaceName, + final String anchorName, + final String deltaReport) { + cpsDeltaService.applyChangesInDeltaReport(dataspaceName, anchorName, deltaReport); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + } diff --git a/cps-rest/src/main/java/org/onap/cps/rest/exceptions/CpsRestExceptionHandler.java b/cps-rest/src/main/java/org/onap/cps/rest/exceptions/CpsRestExceptionHandler.java index 28d74aff3a..84cbad81d1 100755 --- a/cps-rest/src/main/java/org/onap/cps/rest/exceptions/CpsRestExceptionHandler.java +++ b/cps-rest/src/main/java/org/onap/cps/rest/exceptions/CpsRestExceptionHandler.java @@ -49,7 +49,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @Slf4j -@RestControllerAdvice(assignableTypes = {AdminRestController.class, DataRestController.class, +@RestControllerAdvice(assignableTypes = {AdminRestController.class, DataRestController.class, DeltaRestController.class, DeltaRestController.class, QueryRestController.class}) @NoArgsConstructor(access = AccessLevel.PACKAGE) public class CpsRestExceptionHandler { diff --git a/cps-rest/src/test/groovy/org/onap/cps/rest/controller/DeltaRestControllerSpec.groovy b/cps-rest/src/test/groovy/org/onap/cps/rest/controller/DeltaRestControllerSpec.groovy index fe9f230fb2..a1eb32ce1c 100644 --- a/cps-rest/src/test/groovy/org/onap/cps/rest/controller/DeltaRestControllerSpec.groovy +++ b/cps-rest/src/test/groovy/org/onap/cps/rest/controller/DeltaRestControllerSpec.groovy @@ -35,6 +35,7 @@ import org.springframework.test.web.servlet.MockMvc import org.springframework.web.multipart.MultipartFile import spock.lang.Shared import spock.lang.Specification + import java.nio.charset.StandardCharsets import java.nio.file.Files import java.nio.file.Path @@ -44,6 +45,7 @@ import static org.onap.cps.api.parameters.FetchDescendantsOption.INCLUDE_ALL_DES import static org.onap.cps.api.parameters.FetchDescendantsOption.OMIT_DESCENDANTS import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post @WebMvcTest(DeltaRestController) class DeltaRestControllerSpec extends Specification { @@ -177,4 +179,18 @@ class DeltaRestControllerSpec extends Specification { then: 'the response contains expected error message' assert response.contentAsString.contains("Parsing error occurred while converting JSON content to Json Node") } + + def 'Apply changes from a delta report, in JSON format, on an anchor'() { + given: 'sample delta report, xpath, and json payload' + def deltaReports = 'some delta report' + def applyDeltaEndpointV2 = "$basePath/v2/dataspaces/$dataspaceName/anchors/$anchorName/applyChangesInDeltaReport" + when: 'request to apply delta report to an anchor is performed using REST API' + def response = + mvc.perform(post(applyDeltaEndpointV2) + .contentType(MediaType.APPLICATION_JSON) + .content(deltaReports) + ).andReturn().response + then: 'expected response code is returned' + assert response.status == HttpStatus.CREATED.value() + } } diff --git a/cps-service/src/main/java/org/onap/cps/api/CpsDeltaService.java b/cps-service/src/main/java/org/onap/cps/api/CpsDeltaService.java index a7f8fc391d..ee2595b286 100644 --- a/cps-service/src/main/java/org/onap/cps/api/CpsDeltaService.java +++ b/cps-service/src/main/java/org/onap/cps/api/CpsDeltaService.java @@ -70,5 +70,13 @@ public interface CpsDeltaService { FetchDescendantsOption fetchDescendantsOption, boolean groupDataNodes); - + /** + * Apply the changes in the given delta report to an anchor. The delta report contains the difference between two + * anchors or an anchor and a configuration. + * + * @param dataspaceName dataspace name + * @param anchorName anchor name where the delta report is to be applied + * @param deltaReportAsJsonString delta report in JSON string format + */ + void applyChangesInDeltaReport(String dataspaceName, String anchorName, String deltaReportAsJsonString); } diff --git a/cps-service/src/main/java/org/onap/cps/impl/CpsDataServiceImpl.java b/cps-service/src/main/java/org/onap/cps/impl/CpsDataServiceImpl.java index 045bba5074..11cb2be034 100644 --- a/cps-service/src/main/java/org/onap/cps/impl/CpsDataServiceImpl.java +++ b/cps-service/src/main/java/org/onap/cps/impl/CpsDataServiceImpl.java @@ -158,10 +158,11 @@ public class CpsDataServiceImpl implements CpsDataService { @Override @Timed(value = "cps.data.service.datanode.leaves.descendants.leaves.update", description = "Time taken to update data node leaves and existing descendants leaves") - public void updateNodeLeavesAndExistingDescendantLeaves(final String dataspaceName, final String anchorName, - final String parentNodeXpath, - final String dataNodeUpdatesAsJson, - final OffsetDateTime observedTimestamp) { + public void updateNodeLeavesAndExistingDescendantLeaves(final String dataspaceName, + final String anchorName, + final String parentNodeXpath, + final String dataNodeUpdatesAsJson, + final OffsetDateTime observedTimestamp) { cpsValidator.validateNameCharacters(dataspaceName, anchorName); final Anchor anchor = cpsAnchorService.getAnchor(dataspaceName, anchorName); final Collection dataNodeUpdates = dataNodeFactory diff --git a/cps-service/src/main/java/org/onap/cps/impl/CpsDeltaServiceImpl.java b/cps-service/src/main/java/org/onap/cps/impl/CpsDeltaServiceImpl.java index 9dcc9cd3b9..9d3d38b943 100644 --- a/cps-service/src/main/java/org/onap/cps/impl/CpsDeltaServiceImpl.java +++ b/cps-service/src/main/java/org/onap/cps/impl/CpsDeltaServiceImpl.java @@ -40,6 +40,7 @@ import org.onap.cps.api.model.DeltaReport; import org.onap.cps.api.parameters.FetchDescendantsOption; import org.onap.cps.utils.DataMapper; import org.onap.cps.utils.JsonObjectMapper; +import org.onap.cps.utils.deltareport.DeltaReportExecutor; import org.onap.cps.utils.deltareport.DeltaReportGenerator; import org.onap.cps.utils.deltareport.GroupedDeltaReportGenerator; import org.springframework.stereotype.Service; @@ -49,6 +50,7 @@ import org.springframework.stereotype.Service; @RequiredArgsConstructor public class CpsDeltaServiceImpl implements CpsDeltaService { + private final DeltaReportExecutor deltaReportExecutor; private final CpsAnchorService cpsAnchorService; private final CpsDataService cpsDataService; private final DataNodeFactory dataNodeFactory; @@ -95,6 +97,19 @@ public class CpsDeltaServiceImpl implements CpsDeltaService { return getDeltaReports(sourceDataNodesRebuilt, targetDataNodes, groupDataNodes); } + /** + * Apply the delta report to the data nodes. + * + * @param dataspaceName name of the dataspace + * @param anchorName name of the anchor + * @param deltaReportAsJsonString JSON string representing the delta report + */ + @Override + public void applyChangesInDeltaReport(final String dataspaceName, final String anchorName, + final String deltaReportAsJsonString) { + deltaReportExecutor.applyChangesInDeltaReport(dataspaceName, anchorName, deltaReportAsJsonString); + } + private List getDeltaReports(final Collection sourceDataNodes, final Collection targetDataNodes, final boolean groupDataNodes) { @@ -134,4 +149,5 @@ public class CpsDeltaServiceImpl implements CpsDeltaService { .createDataNodesWithYangResourceXpathAndNodeData(yangResourceContentPerName, xpath, targetData, JSON); } } + } diff --git a/cps-service/src/main/java/org/onap/cps/utils/JsonObjectMapper.java b/cps-service/src/main/java/org/onap/cps/utils/JsonObjectMapper.java index 253c776f4c..16c6828f5d 100644 --- a/cps-service/src/main/java/org/onap/cps/utils/JsonObjectMapper.java +++ b/cps-service/src/main/java/org/onap/cps/utils/JsonObjectMapper.java @@ -1,6 +1,7 @@ /* * ============LICENSE_START======================================================= * Copyright (C) 2022 Nordix Foundation + * Modifications Copyright (C) 2025 TechMahindra Ltd. * ================================================================================ * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -23,6 +24,8 @@ package org.onap.cps.utils; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.type.CollectionType; +import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.onap.cps.api.exceptions.DataValidationException; @@ -120,4 +123,24 @@ public class JsonObjectMapper { + "JSON content to Json Node.", e.getMessage()); } } + + /** + * Deserialize JSON content from given JSON content String to List of specific class type. + * + * @param jsonContent JSON content + * @param collectionEntryType compatible Object class type + * @param type parameter + * @return a list of specific class type 'T' + */ + public List convertToJsonArray(final String jsonContent, final Class collectionEntryType) { + try { + final CollectionType collectionType = + objectMapper.getTypeFactory().constructCollectionType(List.class, collectionEntryType); + return objectMapper.readValue(jsonContent, collectionType); + } catch (final JsonProcessingException e) { + log.error("Parsing error occurred while converting JSON content to specific class type."); + throw new DataValidationException("Parsing error occurred while converting " + + "JSON content to specific class type.", e.getMessage()); + } + } } diff --git a/cps-service/src/main/java/org/onap/cps/utils/deltareport/DeltaReportExecutor.java b/cps-service/src/main/java/org/onap/cps/utils/deltareport/DeltaReportExecutor.java new file mode 100644 index 0000000000..d066a82bad --- /dev/null +++ b/cps-service/src/main/java/org/onap/cps/utils/deltareport/DeltaReportExecutor.java @@ -0,0 +1,105 @@ +/* + * ============LICENSE_START======================================================= + * Copyright (C) 2025 TechMahindra Ltd. + * ================================================================================ + * Licensed 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. + * + * SPDX-License-Identifier: Apache-2.0 + * ============LICENSE_END========================================================= + */ + +package org.onap.cps.utils.deltareport; + +import static org.onap.cps.utils.ContentType.JSON; + +import java.time.OffsetDateTime; +import java.util.Collection; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.onap.cps.api.CpsAnchorService; +import org.onap.cps.api.CpsDataService; +import org.onap.cps.api.DataNodeFactory; +import org.onap.cps.api.model.Anchor; +import org.onap.cps.api.model.DataNode; +import org.onap.cps.api.model.DeltaReport; +import org.onap.cps.cpspath.parser.CpsPathUtil; +import org.onap.cps.utils.JsonObjectMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class DeltaReportExecutor { + + private static final OffsetDateTime NO_TIMESTAMP = null; + + private final CpsAnchorService cpsAnchorService; + private final CpsDataService cpsDataService; + private final DataNodeFactory dataNodeFactory; + private final JsonObjectMapper jsonObjectMapper; + + /** + * Applies the delta report to the specified dataspace and anchor. + * + * @param dataspaceName the name of the dataspace + * @param anchorName the name of the anchor + * @param deltaReportAsJsonString the delta report as a JSON string + */ + @Transactional + public void applyChangesInDeltaReport(final String dataspaceName, final String anchorName, + final String deltaReportAsJsonString) { + final List deltaReports = + jsonObjectMapper.convertToJsonArray(deltaReportAsJsonString, DeltaReport.class); + for (final DeltaReport deltaReport: deltaReports) { + final String action = deltaReport.getAction(); + final String xpath = deltaReport.getXpath(); + if (action.equals(DeltaReport.REPLACE_ACTION)) { + final String dataForUpdate = jsonObjectMapper.asJsonString(deltaReport.getTargetData()); + updateDataNodes(dataspaceName, anchorName, xpath, dataForUpdate); + } else if (action.equals(DeltaReport.REMOVE_ACTION)) { + final String dataForDelete = jsonObjectMapper.asJsonString(deltaReport.getSourceData()); + deleteDataNodesUsingDelta(dataspaceName, anchorName, xpath, dataForDelete); + } else { + final String dataForCreate = jsonObjectMapper.asJsonString(deltaReport.getTargetData()); + addDataNodesUsingDelta(dataspaceName, anchorName, xpath, dataForCreate); + } + } + } + + private void updateDataNodes(final String dataspaceName, final String anchorName, final String xpath, + final String updatedData) { + cpsDataService.updateNodeLeavesAndExistingDescendantLeaves(dataspaceName, anchorName, + CpsPathUtil.getNormalizedParentXpath(xpath), updatedData, NO_TIMESTAMP); + } + + private void deleteDataNodesUsingDelta(final String dataspaceName, final String anchorName, final String xpath, + final String dataToDelete) { + final Anchor anchor = cpsAnchorService.getAnchor(dataspaceName, anchorName); + final Collection dataNodesToDelete = + dataNodeFactory.createDataNodesWithAnchorParentXpathAndNodeData(anchor, xpath, dataToDelete, JSON); + final Collection xpathsToDelete = dataNodesToDelete.stream().map(DataNode::getXpath).toList(); + cpsDataService.deleteDataNodes(dataspaceName, anchorName, xpathsToDelete, NO_TIMESTAMP); + } + + private void addDataNodesUsingDelta(final String dataspaceName, final String anchorName, final String xpath, + final String dataToAdd) { + final String xpathToAdd = isRootListNodeXpath(xpath) ? CpsPathUtil.ROOT_NODE_XPATH : xpath; + cpsDataService.saveListElements(dataspaceName, anchorName, xpathToAdd, dataToAdd, NO_TIMESTAMP, JSON); + } + + private boolean isRootListNodeXpath(final String xpath) { + return CpsPathUtil.getNormalizedParentXpath(xpath).isEmpty() && CpsPathUtil.isPathToListElement(xpath); + } +} diff --git a/cps-service/src/test/groovy/org/onap/cps/impl/CpsDeltaServiceImplSpec.groovy b/cps-service/src/test/groovy/org/onap/cps/impl/CpsDeltaServiceImplSpec.groovy index 089c19ef6b..7aaeedb5ab 100644 --- a/cps-service/src/test/groovy/org/onap/cps/impl/CpsDeltaServiceImplSpec.groovy +++ b/cps-service/src/test/groovy/org/onap/cps/impl/CpsDeltaServiceImplSpec.groovy @@ -36,6 +36,7 @@ import org.onap.cps.utils.JsonObjectMapper import org.onap.cps.utils.PrefixResolver import org.onap.cps.utils.YangParser import org.onap.cps.utils.YangParserHelper +import org.onap.cps.utils.deltareport.DeltaReportExecutor import org.onap.cps.utils.deltareport.DeltaReportGenerator import org.onap.cps.utils.deltareport.DeltaReportHelper import org.onap.cps.utils.deltareport.GroupedDeltaReportGenerator @@ -54,6 +55,7 @@ class CpsDeltaServiceImplSpec extends Specification { def mockCpsAnchorService = Mock(CpsAnchorService) def mockCpsDataService = Mock(CpsDataService) + def mockDeltaReportExecutor = Mock(DeltaReportExecutor) def mockYangTextSchemaSourceSetCache = Mock(YangTextSchemaSourceSetCache) def mockTimedYangTextSchemaSourceSetBuilder = Mock(TimedYangTextSchemaSourceSetBuilder) def yangParser = new YangParser(new YangParserHelper(), mockYangTextSchemaSourceSetCache, mockTimedYangTextSchemaSourceSetBuilder) @@ -64,7 +66,7 @@ class CpsDeltaServiceImplSpec extends Specification { def deltaReportHelper = new DeltaReportHelper() def deltaReportGenerator = new DeltaReportGenerator(deltaReportHelper) def groupedDeltaReportGenerator = new GroupedDeltaReportGenerator(deltaReportHelper) - def objectUnderTest = new CpsDeltaServiceImpl(mockCpsAnchorService, mockCpsDataService, dataNodeFactory, dataMapper, jsonObjectMapper, deltaReportGenerator, groupedDeltaReportGenerator) + def objectUnderTest = new CpsDeltaServiceImpl(mockDeltaReportExecutor, mockCpsAnchorService, mockCpsDataService, dataNodeFactory, dataMapper, jsonObjectMapper, deltaReportGenerator, groupedDeltaReportGenerator) static def bookstoreDataNodeWithParentXpath = [new DataNode(xpath: '/bookstore', leaves: ['bookstore-name': 'Easons'])] static def bookstoreDataNodeWithChildXpath = [new DataNode(xpath: '/bookstore/categories[@code=\'02\']', leaves: ['code': '02', 'name': 'Kids'])] @@ -334,7 +336,16 @@ class CpsDeltaServiceImplSpec extends Specification { 'removed' | bookstoreDataNodesWithChildXpathAndNoLeaves | bookstoreDataNodeWithChildXpath || null | ['categories': [['code': '02', 'name': 'Kids']]] } - def setupSchemaSetMocks(String... yangResources) { + def 'Apply changes from a delta report to an anchor'() { + given: 'delta report as JSON string' + def deltaReportJson = '[{"action":"replace","xpath":"/bookstore","sourceData":{"bookstore":{"bookstore-name":"Easons"}},"targetData":{"bookstore":{"bookstore-name":"My Store"}}}]' + when: 'an attempt to apply the delta report to the anchor' + objectUnderTest.applyChangesInDeltaReport(dataspaceName, ANCHOR_NAME_1, deltaReportJson) + then: 'utility class to apply the delta report is invoked with expected parameters' + 1 * mockDeltaReportExecutor.applyChangesInDeltaReport(dataspaceName, ANCHOR_NAME_1, deltaReportJson) + } + + def setupSchemaSetMocks(yangResources) { def mockYangTextSchemaSourceSet = Mock(YangTextSchemaSourceSet) mockYangTextSchemaSourceSetCache.get(dataspaceName, schemaSetName) >> mockYangTextSchemaSourceSet def yangResourceNameToContent = TestUtils.getYangResourcesAsMap(yangResources) @@ -342,7 +353,7 @@ class CpsDeltaServiceImplSpec extends Specification { mockYangTextSchemaSourceSet.getSchemaContext() >> schemaContext } - def setupSchemaSetMocksForDelta(Map yangResourceContentPerName) { + def setupSchemaSetMocksForDelta(yangResourceContentPerName) { def mockYangTextSchemaSourceSet = Mock(YangTextSchemaSourceSet) mockTimedYangTextSchemaSourceSetBuilder.getYangTextSchemaSourceSet(yangResourceContentPerName) >> mockYangTextSchemaSourceSet mockYangTextSchemaSourceSetCache.get(_, _) >> mockYangTextSchemaSourceSet diff --git a/cps-service/src/test/groovy/org/onap/cps/utils/JsonObjectMapperSpec.groovy b/cps-service/src/test/groovy/org/onap/cps/utils/JsonObjectMapperSpec.groovy index ace786a5ab..49a79fd04e 100644 --- a/cps-service/src/test/groovy/org/onap/cps/utils/JsonObjectMapperSpec.groovy +++ b/cps-service/src/test/groovy/org/onap/cps/utils/JsonObjectMapperSpec.groovy @@ -1,6 +1,7 @@ /* * ============LICENSE_START======================================================= * Copyright (C) 2022 Nordix Foundation + * Modifications Copyright (C) 2025 TechMahindra Ltd. * ================================================================================ * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -136,4 +137,23 @@ class JsonObjectMapperSpec extends Specification { then: 'a data validation exception is thrown' thrown(DataValidationException) } + + def 'Convert JSON content to a list of specific class type'() { + given: 'a JSON array string' + def jsonContent = '[{"key":"value1"}, {"key":"value2"}]' + when: 'The JSON content is converted to a list of the target class type' + def result = jsonObjectMapper.convertToJsonArray(jsonContent, Map) + then: 'The result is a list of the target class type and has expected content' + assert result == [[key: 'value1'], [key: 'value2']] + } + + def 'Throw exception when JSON content is invalid for list conversion'() { + given: 'An invalid JSON array string' + def jsonContent = '[{"key":"value1", {"key":"value2"}' + when: 'an attempt to convert JSON content to a list of the target class type' + jsonObjectMapper.convertToJsonArray(jsonContent, Map) + then: 'a DataValidationException is thrown' + def thrown = thrown(DataValidationException) + thrown.message.contains('Parsing error occurred while converting JSON content to specific class type.') + } } diff --git a/cps-service/src/test/groovy/org/onap/cps/utils/deltareport/DeltaReportExecutorSpec.groovy b/cps-service/src/test/groovy/org/onap/cps/utils/deltareport/DeltaReportExecutorSpec.groovy new file mode 100644 index 0000000000..7c27efc64c --- /dev/null +++ b/cps-service/src/test/groovy/org/onap/cps/utils/deltareport/DeltaReportExecutorSpec.groovy @@ -0,0 +1,157 @@ +/* + * ============LICENSE_START======================================================= + * Copyright (C) 2025 TechMahindra Ltd + * ================================================================================ + * Licensed 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. + * + * SPDX-License-Identifier: Apache-2.0 + * ============LICENSE_END========================================================= + */ + +package org.onap.cps.utils.deltareport + + +import com.fasterxml.jackson.databind.ObjectMapper +import org.onap.cps.TestUtils +import org.onap.cps.api.CpsAnchorService +import org.onap.cps.api.CpsDataService +import org.onap.cps.api.exceptions.DataValidationException +import org.onap.cps.api.model.Anchor +import org.onap.cps.api.model.DataNode +import org.onap.cps.api.model.DeltaReport +import org.onap.cps.cpspath.parser.CpsPathUtil +import org.onap.cps.impl.DataNodeFactoryImpl +import org.onap.cps.impl.DeltaReportBuilder +import org.onap.cps.impl.YangTextSchemaSourceSetCache +import org.onap.cps.utils.ContentType +import org.onap.cps.utils.JsonObjectMapper +import org.onap.cps.utils.YangParser +import org.onap.cps.utils.YangParserHelper +import org.onap.cps.yang.TimedYangTextSchemaSourceSetBuilder +import org.onap.cps.yang.YangTextSchemaSourceSet +import org.onap.cps.yang.YangTextSchemaSourceSetBuilder +import spock.lang.Shared +import spock.lang.Specification + +class DeltaReportExecutorSpec extends Specification { + + def mockCpsAnchorService = Mock(CpsAnchorService) + def mockCpsDataService = Mock(CpsDataService) + def mockYangTextSchemaSourceSetCache = Mock(YangTextSchemaSourceSetCache) + def mockTimedYangTextSchemaSourceSetBuilder = Mock(TimedYangTextSchemaSourceSetBuilder) + def yangParser = new YangParser(new YangParserHelper(), mockYangTextSchemaSourceSetCache, mockTimedYangTextSchemaSourceSetBuilder) + def dataNodeFactory = new DataNodeFactoryImpl(yangParser) + def jsonObjectMapper = new JsonObjectMapper(new ObjectMapper()) + def objectUnderTest = new DeltaReportExecutor(mockCpsAnchorService, mockCpsDataService, dataNodeFactory, jsonObjectMapper) + + @Shared + static def ANCHOR_NAME_1 = 'some-anchor-1' + static def ANCHOR_NAME_2 = 'some-anchor-2' + static def NO_TIMESTAMP = null + def dataspaceName = 'some-dataspace' + def schemaSetName = 'some-schema-set' + def anchor1 = Anchor.builder().name(ANCHOR_NAME_1).dataspaceName(dataspaceName).schemaSetName(schemaSetName).build() + def anchor2 = Anchor.builder().name(ANCHOR_NAME_2).dataspaceName(dataspaceName).schemaSetName(schemaSetName).build() + + def setup() { + mockCpsAnchorService.getAnchor(dataspaceName, ANCHOR_NAME_1) >> anchor1 + mockCpsAnchorService.getAnchor(dataspaceName, ANCHOR_NAME_2) >> anchor2 + } + + def 'Perform delete operation on existing data under an anchor using delta report'() { + given: 'schema mocks and delta report in JSON format' + setupSchemaSetMocks('bookstore.yang') + def deltaReportJson = '[{"action":"remove","xpath":"/bookstore","sourceData":{"categories":[{"code":"1","name":"Children","books":[{"title":"Matilda"}]}]}}]' + and: 'delta report constructed from JSON' + def deltaReport = new DeltaReportBuilder().actionRemove().withXpath('/bookstore').withSourceData('categories': [['code': '1', 'name': 'Children', 'books': [['title': 'Matilda']]]]).build() + and: 'source data as JSON string from delta report' + def sourceData = jsonObjectMapper.asJsonString(deltaReport.getSourceData()) + and: 'expected data nodes with child nodes to delete' + def dataNodes = [new DataNode(xpath: '/bookstore/categories[@code=\'1\']', childDataNodes: [new DataNode(xpath: '/bookstore/categories[@code=\'1\']/books[@title=\'Matilda\']')])] + def xpathsToDelete = dataNodes*.xpath + when: 'attempt to apply delta using the delta report' + objectUnderTest.applyChangesInDeltaReport(dataspaceName, ANCHOR_NAME_1, deltaReportJson) + then: 'the delta report in JSON format is converted to a list of DeltaReport objects' + jsonObjectMapper.convertToJsonArray(deltaReportJson, DeltaReport) >> [deltaReport] + and: 'data nodes are built from the source data of delta report' + dataNodeFactory.createDataNodesWithAnchorParentXpathAndNodeData(anchor1, deltaReport.getXpath(), sourceData, ContentType.JSON) >> [dataNodes] + and: 'appropriate cps data service method is invoked with expected parameters to delete data nodes' + 1 * mockCpsDataService.deleteDataNodes(dataspaceName, ANCHOR_NAME_1, xpathsToDelete, NO_TIMESTAMP) + } + + def 'Perform create operation on existing data under an anchor using delta report to add a node with #scenario'() { + given: 'schema mocks and delta report in JSON format' + setupSchemaSetMocks('bookstore.yang') + and: 'target data as JSON string from delta report' + def targetData = jsonObjectMapper.asJsonString(deltaReport.getTargetData()) + when: 'attempt to apply delta using the delta report' + objectUnderTest.applyChangesInDeltaReport(dataspaceName, ANCHOR_NAME_1, deltaReportJson) + then: 'the delta report in JSON format is converted to a list of DeltaReport objects' + jsonObjectMapper.convertToJsonArray(deltaReportJson, DeltaReport) >> [deltaReport] + and: 'appropriate cps data service method is invoked with expected parameters to create data nodes' + 1 * mockCpsDataService.saveListElements(dataspaceName, ANCHOR_NAME_1, expectedXpath, targetData, NO_TIMESTAMP, ContentType.JSON) + where: + scenario | deltaReportJson | deltaReport || expectedXpath + 'xpath' | '[{"action":"create","xpath":"/bookstore","targetData":{"categories":[{"code":"1","name":"Children"}]}}]' | new DeltaReportBuilder().actionCreate().withXpath('/bookstore').withTargetData(['categories': [['code': '1', 'name': 'Children']]]).build() || '/bookstore' + 'list node xpath' | '[{"action":"create","xpath":"/bookstore/categories[@code=\'1\']","targetData":{"books":[{"price":20,"title":"Matilda"}]}}]' | new DeltaReportBuilder().actionCreate().withXpath('/bookstore/categories[@code=\'1\']').withTargetData(['books': [['price': 20, 'title': 'Matilda']]]).build() || '/bookstore/categories[@code=\'1\']' + 'parent list node xpath' | '[{"action":"create","xpath":"/bookstore-address[@bookstore-name=\'Easons\']","targetData":{"address":{"street":"Main Street"}}}]' | new DeltaReportBuilder().actionCreate().withXpath('/bookstore-address[@bookstore-name=\'Easons\']').withTargetData(['address': ['street': 'Main Street']]).build() || '/' + } + + def 'Perform replace operation on existing data under an anchor using delta report'() { + given: 'schema mocks and delta report in JSON format' + setupSchemaSetMocks('bookstore.yang') + def deltaReportJson = '[{"action":"replace","xpath":"/bookstore/categories[@code=\'1\']","sourceData":{"books":[{"price":20,"title":"Matilda"}]},"targetData":{"books":[{"price":30,"title":"Matilda"}]}}]' + and: 'delta report constructed from JSON' + def deltaReport = new DeltaReportBuilder().actionReplace().withXpath('/bookstore/categories[@code=\'1\']').withSourceData(['books': [['price': 20, 'title': 'Matilda']]]).withTargetData(['books': [['price': 30, 'title': 'Matilda']]]).build() + and: 'the parent node xpath is fetched from delta report' + def parentNodeXpath = CpsPathUtil.getNormalizedParentXpath(deltaReport.getXpath()) + and: 'target data as JSON is fetched from delta report' + def targetData = jsonObjectMapper.asJsonString(deltaReport.getTargetData()) + when: 'attempt to apply delta using the delta report' + objectUnderTest.applyChangesInDeltaReport(dataspaceName, ANCHOR_NAME_1, deltaReportJson) + then: 'the delta report in JSON format is converted to a list of DeltaReport objects' + jsonObjectMapper.convertToJsonArray(deltaReportJson, DeltaReport) >> [deltaReport] + and: 'cps data service is invoked with expected parameters to delete data nodes by using their xpaths' + 1 * mockCpsDataService.updateNodeLeavesAndExistingDescendantLeaves(dataspaceName, ANCHOR_NAME_1, parentNodeXpath, targetData, NO_TIMESTAMP) + } + + def 'Batch operation using delta report rolls back in case of a semantically invalid Delta Report'() { + given: 'schema mocks and a semantically invalid delta report in JSON format' + setupSchemaSetMocks('bookstore.yang') + def deltaReportJson = '[{"action":"create","xpath":"/bookstore/categories[@code=\'100\']","targetData":{"categories":[{"code":"100","name":"Funny"}]}},{"action":"remove","xpath":"/bookstore/categories[@code=\'4\']","sourceData":{"categorie":[{"code":"4","name":"Computing"}]}}]' + and: 'delta report object with invalid data for remove operation' + def deltaReport = [new DeltaReportBuilder().actionCreate().withXpath('/bookstore/categories[@code=\'100\']').withTargetData(['categories': [['code': '100', 'name': 'Funny']]]).build(), new DeltaReportBuilder().actionRemove().withXpath('/bookstore/categories[@code=\'4\'').withSourceData(['categorie': ['code': '4', 'name': 'Computing']]).build()] + and: 'appropriate data is fetched from delta report' + def xpathForCreateOperation = deltaReport[0].xpath + def targetDataForCreateOperation = jsonObjectMapper.asJsonString(deltaReport[0].targetData) + def xpathForDeleteOperation = deltaReport[1].xpath + def sourceDataForDeleteOperation = jsonObjectMapper.asJsonString(deltaReport[1].sourceData) + when: 'attempt to apply delta using the delta report' + objectUnderTest.applyChangesInDeltaReport(dataspaceName, ANCHOR_NAME_1, deltaReportJson) + then: 'the delta report in JSON format is converted to DeltaReport objects' + jsonObjectMapper.convertToJsonArray(deltaReportJson, DeltaReport) >> [deltaReport] + and: 'the create operation is attempted and succeeds' + 1 * mockCpsDataService.saveListElements(dataspaceName, ANCHOR_NAME_1, xpathForCreateOperation, targetDataForCreateOperation, NO_TIMESTAMP, ContentType.JSON) + and: 'the remove operation fails due to invalid data, causing rollback' + dataNodeFactory.createDataNodesWithAnchorParentXpathAndNodeData(anchor1, xpathForDeleteOperation, sourceDataForDeleteOperation, ContentType.JSON) >> {throw new DataValidationException('Data Validation Failed')} + then: 'a DataValidationException is thrown' + thrown(DataValidationException) + } + + def setupSchemaSetMocks(yangResources) { + def mockYangTextSchemaSourceSet = Mock(YangTextSchemaSourceSet) + mockYangTextSchemaSourceSetCache.get(dataspaceName, schemaSetName) >> mockYangTextSchemaSourceSet + def yangResourceNameToContent = TestUtils.getYangResourcesAsMap(yangResources) + def schemaContext = YangTextSchemaSourceSetBuilder.of(yangResourceNameToContent).getSchemaContext() + mockYangTextSchemaSourceSet.getSchemaContext() >> schemaContext + } +}