Inconsistency With JSON Response(List Items) Using ReplaceANode API 18/140718/19
authorRudrangi Anupriya <ra00745022@techmahindra.com>
Thu, 17 Jul 2025 11:09:13 +0000 (16:39 +0530)
committerGM001016278 <gourav.malviya@techmahindra.com>
Tue, 2 Sep 2025 13:04:38 +0000 (18:34 +0530)
Jira - https://lf-onap.atlassian.net/browse/CPS-2800
Documentation - https://lf-onap.atlassian.net/wiki/x/gIDMAQ

- Updated DataRestController.java to return 201 for new nodes
- CpsDataService.java Changed method signature to return boolean
- Added logic to detect new nodes in CpsDataServiceImpl.java
- Implements the new logic for detecting and inserting new nodes in CpsDataPersistenceServiceImpl.java
- Added testcases for above changes

Issue-ID: CPS-2800
Change-Id: Ibc9bbb88ccbb07302355321c6d5c2eade0e7e5fb
Signed-off-by: Rudrangi Anupriya <ra00745022@techmahindra.com>
Signed-off-by: GM001016278 <gourav.malviya@techmahindra.com>
cps-rest/docs/openapi/cpsData.yml
cps-rest/src/main/java/org/onap/cps/rest/controller/DataRestController.java
cps-rest/src/test/groovy/org/onap/cps/rest/controller/DataRestControllerSpec.groovy
cps-ri/src/main/java/org/onap/cps/ri/CpsDataPersistenceServiceImpl.java
cps-ri/src/test/groovy/org/onap/cps/ri/CpsDataPersistenceServiceImplSpec.groovy
cps-service/src/main/java/org/onap/cps/api/CpsDataService.java
cps-service/src/main/java/org/onap/cps/impl/CpsDataServiceImpl.java
cps-service/src/main/java/org/onap/cps/spi/CpsDataPersistenceService.java
docs/api/swagger/cps/openapi.yaml

index 178a68f..7ab1982 100644 (file)
@@ -1,7 +1,7 @@
 # ============LICENSE_START=======================================================
 # Copyright (c) 2021-2022 Bell Canada.
 # Modifications Copyright (C) 2021-2022 Nordix Foundation
-# Modifications Copyright (C) 2022-2024 TechMahindra Ltd.
+# Modifications Copyright (C) 2022-2025 TechMahindra Ltd.
 # Modifications Copyright (C) 2022 Deutsche Telekom AG
 # ================================================================================
 # Licensed under the Apache License, Version 2.0 (the "License");
@@ -240,6 +240,8 @@ nodesByDataspaceAndAnchor:
     responses:
       '200':
         $ref: 'components.yml#/components/responses/Ok'
+      '201':
+        $ref: 'components.yml#/components/responses/Created'
       '400':
         $ref: 'components.yml#/components/responses/BadRequest'
       '403':
index f2dc4e9..103ed69 100755 (executable)
@@ -169,11 +169,11 @@ public class DataRestController implements CpsDataApi {
         if (Boolean.TRUE.equals(dryRunEnabled)) {
             cpsDataService.validateData(dataspaceName, anchorName, parentNodeXpath, nodeData, contentType);
             return ResponseEntity.ok().build();
-        } else {
-            cpsDataService.updateDataNodeAndDescendants(dataspaceName, anchorName, parentNodeXpath,
-                    nodeData, toOffsetDateTime(observedTimestamp), contentType);
         }
-        return ResponseEntity.status(HttpStatus.OK).build();
+        final boolean hasNewDataNodes  = cpsDataService.updateDataNodeAndDescendants(dataspaceName, anchorName,
+            parentNodeXpath, nodeData, toOffsetDateTime(observedTimestamp), contentType);
+        final HttpStatus httpStatus = hasNewDataNodes  ? HttpStatus.CREATED : HttpStatus.OK;
+        return ResponseEntity.status(httpStatus).build();
     }
 
     @Override
index 3762b6e..cf1c49e 100755 (executable)
@@ -420,6 +420,27 @@ class DataRestControllerSpec extends Specification {
             'XML content: some xpath by parent'  | '/some/xpath' | MediaType.APPLICATION_XML  || '/some/xpath'         | requestBodyXml  | expectedXmlData  | ContentType.XML
     }
 
+    def 'Replace data node tree returns #hasNewNodes for #scenario.'() {
+        given: 'endpoint to replace node'
+            def endpoint = "$dataNodeBaseEndpointV2/anchors/$anchorName/nodes"
+        when: 'put request is performed'
+            def response =
+                mvc.perform(
+                    put(endpoint)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(requestBodyJson)
+                        .param('xpath', ''))
+                    .andReturn().response
+        then: 'the cps data service method is invoked with expected parameters'
+            1 * mockCpsDataService.updateDataNodeAndDescendants(dataspaceName, anchorName, '/', expectedJsonData, noTimestamp, ContentType.JSON) >> hasNewNodes
+        and: 'response status indicates success or creation'
+         assert response.status == expectedStatus
+        where:
+            scenario                                      | hasNewNodes || expectedStatus
+            'JSON content: root node updated only'        | false       || HttpStatus.OK.value()
+            'JSON content: root node with new list items' | true        || HttpStatus.CREATED.value()
+    }
+
     def 'Validate data using Replace data node API.'() {
         given: 'endpoint to replace node'
             def endpoint = "$dataNodeBaseEndpointV1/anchors/$anchorName/nodes"
index 472da34..6000030 100644 (file)
@@ -3,7 +3,7 @@
  *  Copyright (C) 2021-2025 OpenInfra Foundation Europe. All rights reserved.
  *  Modifications Copyright (C) 2021 Pantheon.tech
  *  Modifications Copyright (C) 2020-2022 Bell Canada.
- *  Modifications Copyright (C) 2022-2023 TechMahindra Ltd.
+ *  Modifications Copyright (C) 2022-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.
@@ -149,26 +149,40 @@ public class CpsDataPersistenceServiceImpl implements CpsDataPersistenceService
     }
 
     @Override
-    public void updateDataNodesAndDescendants(final String dataspaceName, final String anchorName,
+    public boolean updateDataNodesAndDescendants(final String dataspaceName, final String anchorName,
                                               final Collection<DataNode> updatedDataNodes) {
         final AnchorEntity anchorEntity = getAnchorEntity(dataspaceName, anchorName);
-
         final Map<String, DataNode> xpathToUpdatedDataNode = updatedDataNodes.stream()
             .collect(Collectors.toMap(DataNode::getXpath, dataNode -> dataNode));
-
         final Collection<String> xpaths = xpathToUpdatedDataNode.keySet();
         Collection<FragmentEntity> existingFragmentEntities = getFragmentEntities(anchorEntity, xpaths);
-
-        logMissingXPaths(xpaths, existingFragmentEntities);
-
+        final List<DataNode> newDataNodes = identifyNewDataNodes(updatedDataNodes, existingFragmentEntities);
         existingFragmentEntities = fragmentRepository.prefetchDescendantsOfFragmentEntities(
             FetchDescendantsOption.INCLUDE_ALL_DESCENDANTS, existingFragmentEntities);
+        updateExistingFragments(existingFragmentEntities, xpathToUpdatedDataNode);
+        addFragmentEntitiesForNewDataNodes(anchorEntity, newDataNodes, existingFragmentEntities);
+        persistFragmentEntitiesWithRetry(anchorEntity, existingFragmentEntities);
+        return !newDataNodes.isEmpty();
+    }
 
+    private void updateExistingFragments(final Collection<FragmentEntity> existingFragmentEntities,
+                                         final Map<String, DataNode> xpathToUpdatedDataNode) {
         for (final FragmentEntity existingFragmentEntity : existingFragmentEntities) {
             final DataNode updatedDataNode = xpathToUpdatedDataNode.get(existingFragmentEntity.getXpath());
             updateFragmentEntityAndDescendantsWithDataNode(existingFragmentEntity, updatedDataNode);
         }
+    }
 
+    private void addFragmentEntitiesForNewDataNodes(final AnchorEntity anchorEntity, final List<DataNode> newNodes,
+                                 final Collection<FragmentEntity> existingFragmentEntities) {
+        for (final DataNode newNode : newNodes) {
+            final FragmentEntity newFragmentEntity = convertToFragmentWithAllDescendants(anchorEntity, newNode);
+            existingFragmentEntities.add(newFragmentEntity);
+        }
+    }
+
+    private void persistFragmentEntitiesWithRetry(final AnchorEntity anchorEntity,
+                                                  final Collection<FragmentEntity> existingFragmentEntities) {
         try {
             fragmentRepository.saveAll(existingFragmentEntities);
         } catch (final StaleStateException staleStateException) {
@@ -176,6 +190,16 @@ public class CpsDataPersistenceServiceImpl implements CpsDataPersistenceService
         }
     }
 
+    private List<DataNode> identifyNewDataNodes(final Collection<DataNode> updatedDataNodes,
+                                            final Collection<FragmentEntity> existingFragmentEntities) {
+        final Set<String> existingXpaths = existingFragmentEntities.stream()
+            .map(FragmentEntity::getXpath)
+            .collect(Collectors.toSet());
+        return updatedDataNodes.stream()
+            .filter(dataNode -> !existingXpaths.contains(dataNode.getXpath()))
+            .collect(Collectors.toList());
+    }
+
     private void retryUpdateDataNodesIndividually(final AnchorEntity anchorEntity,
                                                   final Collection<FragmentEntity> fragmentEntities) {
         final Collection<String> failedXpaths = new HashSet<>();
index e927922..9303275 100644 (file)
@@ -2,7 +2,7 @@
  * ============LICENSE_START=======================================================
  * Copyright (c) 2021 Bell Canada.
  * Modifications Copyright (C) 2021-2023 Nordix Foundation
- * Modifications Copyright (C) 2022-2023 TechMahindra Ltd.
+ * Modifications Copyright (C) 2022-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.
@@ -243,6 +243,28 @@ class CpsDataPersistenceServiceImplSpec extends Specification {
             }})
     }
 
+    def 'Replace data nodes and add new list item'() {
+        given: 'The fragment repository returns existing fragment for known XPath'
+            def existingFragmentEntity = new FragmentEntity(id: 1, xpath: '/bookstore/categories[@code=1]/books[@title="Matilda"]', attributes: '{"title":"Matilda","lang":"English"}', anchor: anchorEntity, childFragments: [] as Set)
+            mockFragmentRepository.findByAnchorAndXpathIn(_, _ as Set) >> [existingFragmentEntity]
+        and: 'a data node that represents replacing an attribute from an existing one'
+            def dataNode1 = new DataNode(xpath: '/bookstore/categories[@code=1]/books[@title="Matilda"]', leaves: ['title': 'Matilda', 'lang': 'French'])
+        and: 'a data node that represents adding a new list item'
+            def dataNode2 = new DataNode(xpath: '/bookstore/categories[@code=1]/books[@title="Book 2"]', leaves: ['title': 'Book 2', 'lang': 'English'])
+        when: 'the fragment entities are updated by the data nodes'
+            def result = objectUnderTest.updateDataNodesAndDescendants('dataspace', 'anchor', [dataNode1, dataNode2])
+        then: 'fragment repository saves the updated new fragments'
+            1 * mockFragmentRepository.saveAll({ fragmentEntities ->
+                {
+                    assert fragmentEntities.size() == 2
+                    def fragmentEntityPerXpath = fragmentEntities.collectEntries { [it.xpath, it] }
+                    assert fragmentEntityPerXpath.get('/bookstore/categories[@code=1]/books[@title="Matilda"]').attributes.contains('"lang":"French"')
+                    assert fragmentEntityPerXpath.get('/bookstore/categories[@code=1]/books[@title="Book 2"]').attributes.contains('"lang":"English"')
+                }})
+        and: 'result confirms new nodes were inserted'
+            assert result
+    }
+
     def createDataNodeAndMockRepositoryMethodSupportingIt(xpath, scenario) {
         def dataNode = new DataNodeBuilder().withXpath(xpath).build()
         def fragmentEntity = new FragmentEntity(xpath: xpath, childFragments: [])
index 5d48812..d86fb92 100644 (file)
@@ -147,9 +147,11 @@ public interface CpsDataService {
      * @param nodeData          node data
      * @param observedTimestamp observedTimestamp
      * @param contentType       JSON/XML content type
+     * @return                  true if new data nodes were created during the update;
+     *                          false if only existing nodes were updated or no changes were applied
      */
-    void updateDataNodeAndDescendants(String dataspaceName, String anchorName, String parentNodeXpath, String nodeData,
-                                       OffsetDateTime observedTimestamp, ContentType contentType);
+    boolean updateDataNodeAndDescendants(String dataspaceName, String anchorName, String parentNodeXpath,
+                                         String nodeData, OffsetDateTime observedTimestamp, ContentType contentType);
 
     /**
      * Replaces multiple existing data nodes' content including descendants in a batch operation.
index 045bba5..c4c18cb 100644 (file)
@@ -196,15 +196,17 @@ public class CpsDataServiceImpl implements CpsDataService {
     @Override
     @Timed(value = "cps.data.service.datanode.descendants.update",
         description = "Time taken to update a data node and descendants")
-    public void updateDataNodeAndDescendants(final String dataspaceName, final String anchorName,
+    public boolean updateDataNodeAndDescendants(final String dataspaceName, final String anchorName,
                                              final String parentNodeXpath, final String nodeData,
                                              final OffsetDateTime observedTimestamp, final ContentType contentType) {
         cpsValidator.validateNameCharacters(dataspaceName, anchorName);
         final Anchor anchor = cpsAnchorService.getAnchor(dataspaceName, anchorName);
         final Collection<DataNode> dataNodes = dataNodeFactory
                 .createDataNodesWithAnchorParentXpathAndNodeData(anchor, parentNodeXpath, nodeData, contentType);
-        cpsDataPersistenceService.updateDataNodesAndDescendants(dataspaceName, anchorName, dataNodes);
+        final boolean hasNewDataNodes = cpsDataPersistenceService
+                .updateDataNodesAndDescendants(dataspaceName, anchorName, dataNodes);
         sendDataUpdatedEvent(anchor, parentNodeXpath, Operation.UPDATE, observedTimestamp);
+        return hasNewDataNodes;
     }
 
     @Override
index 138fc34..0e3d864 100644 (file)
@@ -3,7 +3,7 @@
  *  Copyright (C) 2020-2025 OpenInfra Foundation Europe. All rights reserved.
  *  Modifications Copyright (C) 2021 Pantheon.tech
  *  Modifications Copyright (C) 2022 Bell Canada
- *  Modifications Copyright (C) 2022-2023 TechMahindra Ltd.
+ *  Modifications Copyright (C) 2022-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.
@@ -114,8 +114,12 @@ public interface CpsDataPersistenceService {
      * @param dataspaceName dataspace name
      * @param anchorName    anchor name
      * @param dataNodes     data nodes
+     * @return              true if new data nodes were created during the update;
+     *                      false if only existing nodes were updated or no changes were applied
+     *
      */
-    void updateDataNodesAndDescendants(String dataspaceName, String anchorName, final Collection<DataNode> dataNodes);
+    boolean updateDataNodesAndDescendants(String dataspaceName, String anchorName, final Collection<DataNode>
+                                                                           dataNodes);
 
     /**
      * Replaces list content by removing all existing elements and inserting the given new elements
index 48b6ff0..82eaa45 100644 (file)
@@ -1829,6 +1829,13 @@ paths:
               schema:
                 type: object
           description: OK
+        "201":
+          content:
+            application/json:
+              schema:
+                example: my-resource
+                type: string
+          description: Created
         "400":
           content:
             application/json: