Data fragment update by xpath #3 - rest and service layers

Issue-ID: CPS-58
Change-Id: Ie224da95b07748b63648226df6484cebae91cdec
Signed-off-by: Ruslan Kashapov <ruslan.kashapov@pantheon.tech>
diff --git a/cps-rest/docs/api/swagger/components.yml b/cps-rest/docs/api/swagger/components.yml
index 9e306cd..3694f36 100755
--- a/cps-rest/docs/api/swagger/components.yml
+++ b/cps-rest/docs/api/swagger/components.yml
@@ -68,9 +68,9 @@
       schema:
         type: string
     xpathInQuery:
-      name: cps-path
+      name: xpath
       in: query
-      description: cps-path
+      description: xpath
       required: false
       schema:
         type: string
diff --git a/cps-rest/docs/api/swagger/cpsData.yml b/cps-rest/docs/api/swagger/cpsData.yml
index 9abace2..eabed28 100755
--- a/cps-rest/docs/api/swagger/cpsData.yml
+++ b/cps-rest/docs/api/swagger/cpsData.yml
@@ -48,6 +48,59 @@
       '403':
         $ref: 'components.yml#/components/responses/Forbidden'
 
+  patch:
+    description: Update a data node leaves for a given dataspace and anchor and a parent node xpath
+    tags:
+      - cps-data
+    summary: Update node leaves
+    operationId: updateNodeLeaves
+    parameters:
+      - $ref: 'components.yml#/components/parameters/dataspaceNameInPath'
+      - $ref: 'components.yml#/components/parameters/anchorNameInPath'
+      - $ref: 'components.yml#/components/parameters/xpathInQuery'
+    requestBody:
+      required: true
+      content:
+        application/json:
+          schema:
+            type: string
+    responses:
+      '200':
+        $ref: 'components.yml#/components/responses/Ok'
+      '400':
+        $ref: 'components.yml#/components/responses/BadRequest'
+      '401':
+        $ref: 'components.yml#/components/responses/Unauthorized'
+      '403':
+        $ref: 'components.yml#/components/responses/Forbidden'
+
+  put:
+    description: Replace a node with descendants for a given dataspace, anchor and a parent node xpath
+    tags:
+      - cps-data
+    summary: Replace a node with descendants
+    operationId: replaceNode
+    parameters:
+      - $ref: 'components.yml#/components/parameters/dataspaceNameInPath'
+      - $ref: 'components.yml#/components/parameters/anchorNameInPath'
+      - $ref: 'components.yml#/components/parameters/xpathInQuery'
+    requestBody:
+      required: true
+      content:
+        application/json:
+          schema:
+            type: string
+    responses:
+      '200':
+        $ref: 'components.yml#/components/responses/Ok'
+      '400':
+        $ref: 'components.yml#/components/responses/BadRequest'
+      '401':
+        $ref: 'components.yml#/components/responses/Unauthorized'
+      '403':
+        $ref: 'components.yml#/components/responses/Forbidden'
+
+
 nodesByDataspace:
   get:
     description: Get all nodes for a given dataspace using an xpath or schema node identifier - DRAFT
diff --git a/cps-rest/src/main/java/org/onap/cps/rest/controller/DataRestController.java b/cps-rest/src/main/java/org/onap/cps/rest/controller/DataRestController.java
index c39a969..8366f06 100755
--- a/cps-rest/src/main/java/org/onap/cps/rest/controller/DataRestController.java
+++ b/cps-rest/src/main/java/org/onap/cps/rest/controller/DataRestController.java
@@ -54,15 +54,29 @@
 
     @Override
     public ResponseEntity<Object> getNodeByDataspaceAndAnchor(final String dataspaceName, final String anchorName,
-        final String cpsPath, final Boolean includeDescendants) {
-        if ("/".equals(cpsPath)) {
+        final String xpath, final Boolean includeDescendants) {
+        if ("/".equals(xpath)) {
             // TODO: extracting data by anchor only (root data node and below)
             return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED);
         }
         final FetchDescendantsOption fetchDescendantsOption = Boolean.TRUE.equals(includeDescendants)
             ? FetchDescendantsOption.INCLUDE_ALL_DESCENDANTS : FetchDescendantsOption.OMIT_DESCENDANTS;
         final DataNode dataNode =
-            cpsDataService.getDataNode(dataspaceName, anchorName, cpsPath, fetchDescendantsOption);
+            cpsDataService.getDataNode(dataspaceName, anchorName, xpath, fetchDescendantsOption);
         return new ResponseEntity<>(DataMapUtils.toDataMap(dataNode), HttpStatus.OK);
     }
+
+    @Override
+    public ResponseEntity<Object> updateNodeLeaves(final String jsonData, final String dataspaceName,
+        final String anchorName, final String parentNodeXpath) {
+        cpsDataService.updateNodeLeaves(dataspaceName, anchorName, parentNodeXpath, jsonData);
+        return new ResponseEntity<>(HttpStatus.OK);
+    }
+
+    @Override
+    public ResponseEntity<Object> replaceNode(final String jsonData, final String dataspaceName,
+        final String anchorName, final String parentNodeXpath) {
+        cpsDataService.replaceNodeTree(dataspaceName, anchorName, parentNodeXpath, jsonData);
+        return new ResponseEntity<>(HttpStatus.OK);
+    }
 }
diff --git a/cps-rest/src/test/groovy/org/onap/cps/rest/controller/DataRestControllerSpec.groovy b/cps-rest/src/test/groovy/org/onap/cps/rest/controller/DataRestControllerSpec.groovy
index a79b5f4..c5fd162 100644
--- a/cps-rest/src/test/groovy/org/onap/cps/rest/controller/DataRestControllerSpec.groovy
+++ b/cps-rest/src/test/groovy/org/onap/cps/rest/controller/DataRestControllerSpec.groovy
@@ -23,7 +23,9 @@
 import static org.onap.cps.spi.FetchDescendantsOption.INCLUDE_ALL_DESCENDANTS
 import static org.onap.cps.spi.FetchDescendantsOption.OMIT_DESCENDANTS
 import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch
 import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put
 
 import org.modelmapper.ModelMapper
 import org.onap.cps.api.CpsAdminService
@@ -72,7 +74,7 @@
 
     @Shared
     static DataNode dataNodeWithLeavesNoChildren = new DataNodeBuilder().withXpath('/xpath')
-            .withLeaves([leaf:'value', leafList:['leaveListElement1','leaveListElement2']]).build()
+            .withLeaves([leaf: 'value', leafList: ['leaveListElement1', 'leaveListElement2']]).build()
 
     @Shared
     static DataNode dataNodeWithChild = new DataNodeBuilder().withXpath('/parent')
@@ -102,8 +104,7 @@
             mockCpsDataService.getDataNode(dataspaceName, anchorName, xpath, OMIT_DESCENDANTS) >> dataNodeWithLeavesNoChildren
         when: 'get request is performed through REST API'
             def response = mvc.perform(
-                    get(dataNodeEndpoint)
-                            .param('cps-path', xpath)
+                    get(dataNodeEndpoint).param('xpath', xpath)
             ).andReturn().response
         then: 'a success response is returned'
             response.status == HttpStatus.OK.value()
@@ -120,18 +121,18 @@
             mockCpsDataService.getDataNode(dataspaceName, anchorName, xpath, expectedCpsDataServiceOption) >> dataNode
         when: 'get request is performed through REST API'
             def response = mvc.perform(get(dataNodeEndpoint)
-                            .param('cps-path', xpath)
-                            .param('include-descendants', urlOption))
-                            .andReturn().response
+                    .param('xpath', xpath)
+                    .param('include-descendants', includeDescendantsOption))
+                    .andReturn().response
         then: 'a success response is returned'
             response.status == HttpStatus.OK.value()
         and: 'the response contains child is #expectChildInResponse'
             response.contentAsString.contains('"child"') == expectChildInResponse
         where:
-            scenario                    | dataNode                     | urlOption || expectedCpsDataServiceOption | expectChildInResponse
-            'no descendants by default' | dataNodeWithLeavesNoChildren | ''        || OMIT_DESCENDANTS             | false
-            'no descendant explicitly'  | dataNodeWithLeavesNoChildren | 'false'   || OMIT_DESCENDANTS             | false
-            'with descendants'          | dataNodeWithChild            | 'true'    || INCLUDE_ALL_DESCENDANTS      | true
+            scenario                    | dataNode                     | includeDescendantsOption || expectedCpsDataServiceOption | expectChildInResponse
+            'no descendants by default' | dataNodeWithLeavesNoChildren | ''                       || OMIT_DESCENDANTS             | false
+            'no descendant explicitly'  | dataNodeWithLeavesNoChildren | 'false'                  || OMIT_DESCENDANTS             | false
+            'with descendants'          | dataNodeWithChild            | 'true'                   || INCLUDE_ALL_DESCENDANTS      | true
     }
 
     @Unroll
@@ -140,7 +141,7 @@
             mockCpsDataService.getDataNode(dataspaceName, anchorName, xpath, _) >> { throw exception }
         when: 'get request is performed through REST API'
             def response = mvc.perform(
-                    get(dataNodeEndpoint).param("cps-path", xpath)
+                    get(dataNodeEndpoint).param("xpath", xpath)
             ).andReturn().response
         then: 'a success response is returned'
             response.status == httpStatus.value()
@@ -151,4 +152,46 @@
             'no data'      | '/x-path' | new DataNodeNotFoundException('', '', '') || HttpStatus.NOT_FOUND
             'empty path'   | ''        | new IllegalStateException()               || HttpStatus.NOT_IMPLEMENTED
     }
+
+    @Unroll
+    def 'Update data node leaves: #scenario.'() {
+        given: 'json data'
+            def jsonData = 'json data'
+        when: 'patch request is performed'
+            def response = mvc.perform(
+                    patch(dataNodeEndpoint)
+                            .contentType(MediaType.APPLICATION_JSON)
+                            .content(jsonData)
+                            .param('xpath', xpath)
+            ).andReturn().response
+        then: 'the service method is invoked with expected parameters'
+            1 * mockCpsDataService.updateNodeLeaves(dataspaceName, anchorName, xpathServiceParameter, jsonData)
+        and: 'response status indicates success'
+            response.status == HttpStatus.OK.value()
+        where:
+            scenario               | xpath    | xpathServiceParameter
+            'root node by default' | ''       | '/'
+            'node by parent xpath' | '/xpath' | '/xpath'
+    }
+
+    @Unroll
+    def 'Replace data node tree: #scenario.'() {
+        given: 'json data'
+            def jsonData = 'json data'
+        when: 'put request is performed'
+            def response = mvc.perform(
+                    put(dataNodeEndpoint)
+                            .contentType(MediaType.APPLICATION_JSON)
+                            .content(jsonData)
+                            .param('xpath', xpath)
+            ).andReturn().response
+        then: 'the service method is invoked with expected parameters'
+            1 * mockCpsDataService.replaceNodeTree(dataspaceName, anchorName, xpathServiceParameter, jsonData)
+        and: 'response status indicates success'
+            response.status == HttpStatus.OK.value()
+        where:
+            scenario               | xpath    | xpathServiceParameter
+            'root node by default' | ''       | '/'
+            'node by parent xpath' | '/xpath' | '/xpath'
+    }
 }
diff --git a/cps-service/src/main/java/org/onap/cps/api/CpsDataService.java b/cps-service/src/main/java/org/onap/cps/api/CpsDataService.java
index 7960d12..54d9258 100644
--- a/cps-service/src/main/java/org/onap/cps/api/CpsDataService.java
+++ b/cps-service/src/main/java/org/onap/cps/api/CpsDataService.java
@@ -53,4 +53,25 @@
     DataNode getDataNode(@NonNull String dataspaceName, @NonNull String anchorName, @NonNull String xpath,
         @NonNull FetchDescendantsOption fetchDescendantsOption);
 
+    /**
+     * Updates data node for given dataspace and anchor using xpath to parent node.
+     *
+     * @param dataspaceName   dataspace name
+     * @param anchorName      anchor name
+     * @param parentNodeXpath xpath to parent node
+     * @param jsonData        json data
+     */
+    void updateNodeLeaves(@NonNull String dataspaceName, @NonNull String anchorName, @NonNull String parentNodeXpath,
+        @NonNull String jsonData);
+
+    /**
+     * Replaces existing data node content including descendants.
+     *
+     * @param dataspaceName   dataspace name
+     * @param anchorName      anchor name
+     * @param parentNodeXpath xpath to parent node
+     * @param jsonData        json data
+     */
+    void replaceNodeTree(@NonNull String dataspaceName, @NonNull String anchorName, @NonNull String parentNodeXpath,
+        @NonNull String jsonData);
 }
diff --git a/cps-service/src/main/java/org/onap/cps/api/impl/CpsDataServiceImpl.java b/cps-service/src/main/java/org/onap/cps/api/impl/CpsDataServiceImpl.java
index d7d25b9..6f7d643 100755
--- a/cps-service/src/main/java/org/onap/cps/api/impl/CpsDataServiceImpl.java
+++ b/cps-service/src/main/java/org/onap/cps/api/impl/CpsDataServiceImpl.java
@@ -38,6 +38,8 @@
 @Service
 public class CpsDataServiceImpl implements CpsDataService {
 
+    private static final String ROOT_NODE_XPATH = "/";
+
     @Autowired
     private CpsDataPersistenceService cpsDataPersistenceService;
 
@@ -52,15 +54,8 @@
 
     @Override
     public void saveData(final String dataspaceName, final String anchorName, final String jsonData) {
-        final Anchor anchor = cpsAdminService.getAnchor(dataspaceName, anchorName);
-        final SchemaContext schemaContext = getSchemaContext(dataspaceName, anchor.getSchemaSetName());
-        final NormalizedNode<?, ?> normalizedNode = YangUtils.parseJsonData(jsonData, schemaContext);
-        final DataNode dataNode = new DataNodeBuilder().withNormalizedNodeTree(normalizedNode).build();
-        cpsDataPersistenceService.storeDataNode(dataspaceName, anchor.getName(), dataNode);
-    }
-
-    private SchemaContext getSchemaContext(final String dataspaceName, final String schemaSetName) {
-        return yangTextSchemaSourceSetCache.get(dataspaceName, schemaSetName).getSchemaContext();
+        final DataNode dataNode = buildDataNodeFromJson(dataspaceName, anchorName, ROOT_NODE_XPATH, jsonData);
+        cpsDataPersistenceService.storeDataNode(dataspaceName, anchorName, dataNode);
     }
 
     @Override
@@ -68,4 +63,41 @@
         final FetchDescendantsOption fetchDescendantsOption) {
         return cpsDataPersistenceService.getDataNode(dataspaceName, anchorName, xpath, fetchDescendantsOption);
     }
+
+    @Override
+    public void updateNodeLeaves(final String dataspaceName, final String anchorName, final String parentNodeXpath,
+        final String jsonData) {
+        final DataNode dataNode = buildDataNodeFromJson(dataspaceName, anchorName, parentNodeXpath, jsonData);
+        cpsDataPersistenceService
+            .updateDataLeaves(dataspaceName, anchorName, dataNode.getXpath(), dataNode.getLeaves());
+    }
+
+    @Override
+    public void replaceNodeTree(final String dataspaceName, final String anchorName, final String parentNodeXpath,
+        final String jsonData) {
+        final DataNode dataNode = buildDataNodeFromJson(dataspaceName, anchorName, parentNodeXpath, jsonData);
+        cpsDataPersistenceService.replaceDataNodeTree(dataspaceName, anchorName, dataNode);
+    }
+
+    private DataNode buildDataNodeFromJson(final String dataspaceName, final String anchorName,
+        final String parentNodeXpath, final String jsonData) {
+
+        final Anchor anchor = cpsAdminService.getAnchor(dataspaceName, anchorName);
+        final SchemaContext schemaContext = getSchemaContext(dataspaceName, anchor.getSchemaSetName());
+
+        if (ROOT_NODE_XPATH.equals(parentNodeXpath)) {
+            final NormalizedNode<?, ?> normalizedNode = YangUtils.parseJsonData(jsonData, schemaContext);
+            return new DataNodeBuilder().withNormalizedNodeTree(normalizedNode).build();
+        }
+
+        final NormalizedNode<?, ?> normalizedNode = YangUtils.parseJsonData(jsonData, schemaContext, parentNodeXpath);
+        return new DataNodeBuilder()
+            .withParentNodeXpath(parentNodeXpath)
+            .withNormalizedNodeTree(normalizedNode)
+            .build();
+    }
+
+    private SchemaContext getSchemaContext(final String dataspaceName, final String schemaSetName) {
+        return yangTextSchemaSourceSetCache.get(dataspaceName, schemaSetName).getSchemaContext();
+    }
 }
\ No newline at end of file
diff --git a/cps-service/src/test/groovy/org/onap/cps/api/impl/CpsDataServiceImplSpec.groovy b/cps-service/src/test/groovy/org/onap/cps/api/impl/CpsDataServiceImplSpec.groovy
index 65a0d54..d561475 100644
--- a/cps-service/src/test/groovy/org/onap/cps/api/impl/CpsDataServiceImplSpec.groovy
+++ b/cps-service/src/test/groovy/org/onap/cps/api/impl/CpsDataServiceImplSpec.groovy
@@ -52,9 +52,7 @@
 
     def 'Saving json data.'() {
         given: 'that the admin service will return an anchor'
-            def anchor = new Anchor()
-            anchor.name = anchorName
-            anchor.schemaSetName = schemaSetName
+            def anchor = Anchor.builder().name(anchorName).schemaSetName(schemaSetName).build()
             mockCpsAdminService.getAnchor(dataspaceName, anchorName) >> anchor
         and: 'the schema source set cache returns a schema source set'
             def mockYangTextSchemaSourceSet = Mock(YangTextSchemaSourceSet)
@@ -72,7 +70,7 @@
     }
 
     @Unroll
-    def 'Get data node with option #fetchChildrenOption'() {
+    def 'Get data node with option #fetchDescendantsOption.'() {
         def xpath = '/xpath'
         def dataNode = new DataNodeBuilder().withXpath(xpath).build()
         given: 'persistence service returns data for get data request'
@@ -82,4 +80,49 @@
         where: 'all fetch options are supported'
             fetchDescendantsOption << FetchDescendantsOption.values()
     }
+
+    @Unroll
+    def 'Update data node leaves: #scenario.'() {
+        given: 'that the admin service will return an anchor'
+            def anchor = Anchor.builder().name(anchorName).schemaSetName(schemaSetName).build()
+            mockCpsAdminService.getAnchor(dataspaceName, anchorName) >> anchor
+        and: 'the schema source set cache returns a schema source set'
+            def mockYangTextSchemaSourceSet = Mock(YangTextSchemaSourceSet)
+            mockYangTextSchemaSourceSetCache.get(dataspaceName, schemaSetName) >> mockYangTextSchemaSourceSet
+        and: 'the schema source sets returns the test-tree schema context'
+            def yangResourceNameToContent = TestUtils.getYangResourcesAsMap('test-tree.yang')
+            def schemaContext = YangTextSchemaSourceSetBuilder.of(yangResourceNameToContent).getSchemaContext()
+            mockYangTextSchemaSourceSet.getSchemaContext() >> schemaContext
+        when: 'update data method is invoked with json data #jsonData and parent node xpath #parentNodeXpath'
+            objectUnderTest.updateNodeLeaves(dataspaceName, anchorName, parentNodeXpath, jsonData)
+        then: 'the persistence service method is invoked with correct parameters'
+            1 * mockCpsDataPersistenceService.updateDataLeaves(dataspaceName, anchorName, nodeXpath, leaves)
+        where: 'following parameters were used'
+            scenario         | parentNodeXpath | jsonData                         | nodeXpath                           | leaves
+            'top level node' | '/'             | '{ "test-tree": {"branch": []}}' | '/test-tree'                        | Collections.emptyMap()
+            'level 2 node'   | '/test-tree'    | '{"branch": [{"name":"Name"}]}'  | '/test-tree/branch[@name=\'Name\']' | ['name': 'Name']
+    }
+
+    @Unroll
+    def 'Replace data node: #scenario.'() {
+        given: 'that the admin service will return an anchor'
+            def anchor = Anchor.builder().name(anchorName).schemaSetName(schemaSetName).build()
+            mockCpsAdminService.getAnchor(dataspaceName, anchorName) >> anchor
+        and: 'the schema source set cache returns a schema source set'
+            def mockYangTextSchemaSourceSet = Mock(YangTextSchemaSourceSet)
+            mockYangTextSchemaSourceSetCache.get(dataspaceName, schemaSetName) >> mockYangTextSchemaSourceSet
+        and: 'the schema source sets returns the test-tree schema context'
+            def yangResourceNameToContent = TestUtils.getYangResourcesAsMap('test-tree.yang')
+            def schemaContext = YangTextSchemaSourceSetBuilder.of(yangResourceNameToContent).getSchemaContext()
+            mockYangTextSchemaSourceSet.getSchemaContext() >> schemaContext
+        when: 'replace data method is invoked with json data #jsonData and parent node xpath #parentNodeXpath'
+            objectUnderTest.replaceNodeTree(dataspaceName, anchorName, parentNodeXpath, jsonData)
+        then: 'the persistence service method is invoked with correct parameters'
+            1 * mockCpsDataPersistenceService.replaceDataNodeTree(dataspaceName, anchorName,
+                    { dataNode -> dataNode.xpath == nodeXpath })
+        where: 'following parameters were used'
+            scenario         | parentNodeXpath | jsonData                         | nodeXpath
+            'top level node' | '/'             | '{ "test-tree": {"branch": []}}' | '/test-tree'
+            'level 2 node'   | '/test-tree'    | '{"branch": [{"name":"Name"}]}'  | '/test-tree/branch[@name=\'Name\']'
+    }
 }
\ No newline at end of file