Fetching data node by xpath - rest and service layers 40/117340/10
authorRuslan Kashapov <ruslan.kashapov@pantheon.tech>
Mon, 1 Feb 2021 08:47:25 +0000 (10:47 +0200)
committerRuslan Kashapov <ruslan.kashapov@pantheon.tech>
Thu, 4 Feb 2021 15:45:06 +0000 (17:45 +0200)
IssueID: CPS-71
Change-Id: I54801fc12a8aa700d85e774780c9990b7f19c747
Signed-off-by: Ruslan Kashapov <ruslan.kashapov@pantheon.tech>
cps-rest/docs/api/swagger/components.yaml
cps-rest/docs/api/swagger/cpsData.yml
cps-rest/src/main/java/org/onap/cps/rest/controller/DataRestController.java
cps-rest/src/main/java/org/onap/cps/rest/exceptions/CpsRestExceptionHandler.java
cps-rest/src/test/groovy/org/onap/cps/rest/controller/DataRestControllerSpec.groovy
cps-service/src/main/java/org/onap/cps/api/CpsDataService.java
cps-service/src/main/java/org/onap/cps/api/impl/CpsDataServiceImpl.java
cps-service/src/main/java/org/onap/cps/spi/FetchChildrenOption.java [new file with mode: 0644]
cps-service/src/main/java/org/onap/cps/utils/DataMapUtils.java [new file with mode: 0644]
cps-service/src/test/groovy/org/onap/cps/api/impl/CpsDataServiceImplSpec.groovy
cps-service/src/test/groovy/org/onap/cps/utils/DataMapUtilsSpec.groovy [new file with mode: 0644]

index 3b36b8b..bc7aa57 100644 (file)
@@ -62,6 +62,22 @@ components:
       required: true
       schema:
         type: string
+    xpathInQuery:
+      name: cps-path
+      in: query
+      description: cps-path
+      required: false
+      schema:
+        type: string
+        default: /
+    includeDescendantsOptionInQuery:
+      name: include-descendants
+      in: query
+      description: include-descendants
+      required: false
+      schema:
+        type: boolean
+        default: false
 
   responses:
     NotFound:
index dcdb99a..97bf21a 100644 (file)
@@ -2,11 +2,13 @@ nodesByDataspaceAndAnchor:
   get:
     tags:
       - cps-data
-    summary: Get a node given an anchor for the given dataspace - DRAFT
+    summary: Get a node given an anchor for the given dataspace
     operationId: getNodeByDataspaceAndAnchor
     parameters:
       - $ref: 'components.yaml#/components/parameters/dataspaceNameInPath'
       - $ref: 'components.yaml#/components/parameters/anchorNameInPath'
+      - $ref: 'components.yaml#/components/parameters/xpathInQuery'
+      - $ref: 'components.yaml#/components/parameters/includeDescendantsOptionInQuery'
     responses:
       200:
         $ref: 'components.yaml#/components/responses/Ok'
@@ -49,7 +51,7 @@ nodesByDataspace:
     tags:
       - cps-data
     summary: Get all nodes for a given dataspace using an xpath or schema node identifier - DRAFT
-    operationId: getNode
+    operationId: getNodeByDataspace
     parameters:
       - $ref: 'components.yaml#/components/parameters/dataspaceNameInPath'
     responses:
index 9b31df5..4f23a8a 100644 (file)
@@ -1,6 +1,7 @@
 /*
- * ============LICENSE_START=======================================================
+ *  ============LICENSE_START=======================================================
  *  Copyright (C) 2020 Bell Canada. All rights reserved.
+ *  Modifications Copyright (C) 2021 Pantheon.tech
  *  ================================================================================
  *  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,9 @@ import javax.validation.Valid;
 import javax.validation.constraints.NotNull;
 import org.onap.cps.api.CpsDataService;
 import org.onap.cps.rest.api.CpsDataApi;
+import org.onap.cps.spi.FetchDescendantsOption;
+import org.onap.cps.spi.model.DataNode;
+import org.onap.cps.utils.DataMapUtils;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.http.HttpStatus;
 import org.springframework.http.ResponseEntity;
@@ -44,13 +48,21 @@ public class DataRestController implements CpsDataApi {
     }
 
     @Override
-    public ResponseEntity<Object> getNode(final String dataspaceName) {
+    public ResponseEntity<Object> getNodeByDataspace(final String dataspaceName) {
         return null;
     }
 
     @Override
-    public ResponseEntity<Object> getNodeByDataspaceAndAnchor(final String dataspaceName, final String anchorName) {
-        return null;
+    public ResponseEntity<Object> getNodeByDataspaceAndAnchor(final String dataspaceName, final String anchorName,
+        final String cpsPath, final Boolean includeDescendants) {
+        if ("/".equals(cpsPath)) {
+            // 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);
+        return new ResponseEntity<>(DataMapUtils.toDataMap(dataNode), HttpStatus.OK);
     }
-
 }
index 2f63663..2599dc4 100644 (file)
@@ -27,6 +27,7 @@ import org.onap.cps.rest.model.ErrorMessage;
 import org.onap.cps.spi.exceptions.CpsAdminException;
 import org.onap.cps.spi.exceptions.CpsException;
 import org.onap.cps.spi.exceptions.DataInUseException;
+import org.onap.cps.spi.exceptions.DataNodeNotFoundException;
 import org.onap.cps.spi.exceptions.DataValidationException;
 import org.onap.cps.spi.exceptions.ModelValidationException;
 import org.onap.cps.spi.exceptions.NotFoundInDataspaceException;
@@ -58,7 +59,7 @@ public class CpsRestExceptionHandler {
         return buildErrorResponse(HttpStatus.BAD_REQUEST, exception.getMessage(), extractDetails(exception));
     }
 
-    @ExceptionHandler({NotFoundInDataspaceException.class})
+    @ExceptionHandler({NotFoundInDataspaceException.class, DataNodeNotFoundException.class})
     public static ResponseEntity<Object> handleNotFoundExceptions(final CpsException exception) {
         return buildErrorResponse(HttpStatus.NOT_FOUND, exception.getMessage(), extractDetails(exception));
     }
index f6df3ce..727a16e 100644 (file)
@@ -1,6 +1,7 @@
 /*
  *  ============LICENSE_START=======================================================
  *  Copyright (C) 2021 Nordix Foundation
+ *  Modifications Copyright (C) 2021 Pantheon.tech
  *  ================================================================================
  *  Licensed under the Apache License, Version 2.0 (the "License");
  *  you may not use this file except in compliance with the License.
 
 package org.onap.cps.rest.controller
 
+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.post
+
+import com.google.common.collect.ImmutableMap
 import org.modelmapper.ModelMapper
 import org.onap.cps.api.CpsAdminService
 import org.onap.cps.api.CpsDataService
 import org.onap.cps.api.CpsModuleService
+import org.onap.cps.spi.exceptions.AnchorNotFoundException
+import org.onap.cps.spi.exceptions.DataNodeNotFoundException
+import org.onap.cps.spi.exceptions.DataspaceNotFoundException
+import org.onap.cps.spi.model.DataNode
+import org.onap.cps.spi.model.DataNodeBuilder
 import org.spockframework.spring.SpringBean
 import org.springframework.beans.factory.annotation.Autowired
 import org.springframework.beans.factory.annotation.Value
@@ -30,9 +42,11 @@ import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
 import org.springframework.http.HttpStatus
 import org.springframework.http.MediaType
 import org.springframework.test.web.servlet.MockMvc
+import spock.lang.Shared
 import spock.lang.Specification
+import spock.lang.Unroll
 
-import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
+import javax.annotation.PostConstruct
 
 @WebMvcTest
 class DataRestControllerSpec extends Specification {
@@ -55,18 +69,73 @@ class DataRestControllerSpec extends Specification {
     @Value('${rest.api.cps-base-path}')
     def basePath
 
+    String dataNodeEndpoint
     def dataspaceName = 'my_dataspace'
     def anchorName = 'my_anchor'
 
+    @Shared
+    static DataNode dataNodeNoChildren = new DataNodeBuilder().withXpath("/xpath")
+            .withLeaves(ImmutableMap.of("leaf", "value")).build()
+
+    @Shared
+    static DataNode dataNodeWithChild = new DataNodeBuilder().withXpath("/parent")
+            .withChildDataNodes(Arrays.asList(
+                    new DataNodeBuilder().withXpath("/parent/child").build()
+            )).build()
+
+    @PostConstruct
+    def initEndpoints() {
+        dataNodeEndpoint = "$basePath/v1/dataspaces/$dataspaceName/anchors/$anchorName/nodes"
+    }
+
     def 'Create a node.'() {
-        given:'an endpoint'
-            def nodeEndpoint ="$basePath/v1/dataspaces/$dataspaceName/anchors/$anchorName/nodes"
+        given: 'an endpoint'
             def json = 'some json (this is not validated)'
         when: 'post is invoked'
-            def response = mvc.perform(post(nodeEndpoint).contentType(MediaType.APPLICATION_JSON).content(json))
-                    .andReturn().response
+            def response = mvc.perform(
+                    post(dataNodeEndpoint).contentType(MediaType.APPLICATION_JSON).content(json)
+            ).andReturn().response
         then: 'the java API is called with the correct parameters'
             1 * mockCpsDataService.saveData(dataspaceName, anchorName, json)
             response.status == HttpStatus.CREATED.value()
     }
+
+    @Unroll
+    def 'Get data node with #scenario.'() {
+        given: 'the service returns data node #scenario'
+            mockCpsDataService.getDataNode(dataspaceName, anchorName, xpath, fetchDescendantsOption) >> dataNode
+        when: 'get request is performed through REST API'
+            def response = mvc.perform(
+                    get(dataNodeEndpoint)
+                            .param('cps-path', xpath)
+                            .param('include-descendants', includeDescendants)
+            ).andReturn().response
+        then: 'assert the success response returned'
+            response.status == HttpStatus.OK.value()
+        and: 'response contains expected value'
+            response.contentAsString.contains(checkString)
+        where:
+            scenario                    | dataNode           | xpath     | includeDescendants | fetchDescendantsOption  || checkString
+            'no descendants by default' | dataNodeNoChildren | '/xpath'  | ''                 | OMIT_DESCENDANTS        || '"leaf"'
+            'no descendant explicitly'  | dataNodeNoChildren | '/xpath'  | 'false'            | OMIT_DESCENDANTS        || '"leaf"'
+            'with descendants'          | dataNodeWithChild  | '/parent' | 'true'             | INCLUDE_ALL_DESCENDANTS || '"child"'
+    }
+
+    @Unroll
+    def 'Get data node error scenario: #scenario.'() {
+        given: 'the service returns throws an exception'
+            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)
+            ).andReturn().response
+        then: 'assert the success response returned'
+            response.status == httpStatus.value()
+        where:
+            scenario       | xpath     | exception                                 || httpStatus
+            'no dataspace' | '/x-path' | new DataspaceNotFoundException('')        || HttpStatus.BAD_REQUEST
+            'no anchor'    | '/x-path' | new AnchorNotFoundException('', '')       || HttpStatus.BAD_REQUEST
+            'no data'      | '/x-path' | new DataNodeNotFoundException('', '', '') || HttpStatus.NOT_FOUND
+            'empty path'   | ''        | new IllegalStateException()               || HttpStatus.NOT_IMPLEMENTED
+    }
 }
\ No newline at end of file
index a8f4965..7960d12 100644 (file)
@@ -1,6 +1,7 @@
 /*
  * ============LICENSE_START=======================================================
  *  Copyright (C) 2020 Nordix Foundation
+ *  Modifications Copyright (C) 2021 Pantheon.tech
  *  ================================================================================
  *  Licensed under the Apache License, Version 2.0 (the "License");
  *  you may not use this file except in compliance with the License.
 package org.onap.cps.api;
 
 import org.checkerframework.checker.nullness.qual.NonNull;
+import org.onap.cps.spi.FetchDescendantsOption;
 import org.onap.cps.spi.exceptions.DataValidationException;
+import org.onap.cps.spi.model.DataNode;
 
 /*
  * Datastore interface for handling CPS data.
  */
 public interface CpsDataService {
+
     /**
      * Persists data for the given anchor and dataspace.
      *
@@ -35,4 +39,18 @@ public interface CpsDataService {
      * @throws DataValidationException when json data is invalid
      */
     void saveData(@NonNull String dataspaceName, @NonNull String anchorName, @NonNull String jsonData);
+
+    /**
+     * Retrieves datanode by XPath for given dataspace and anchor.
+     *
+     * @param dataspaceName          dataspace name
+     * @param anchorName             anchor name
+     * @param xpath                  xpath
+     * @param fetchDescendantsOption defines the scope of data to fetch: either single node or all the descendant nodes
+     *                               (recursively) as well
+     * @return data node object
+     */
+    DataNode getDataNode(@NonNull String dataspaceName, @NonNull String anchorName, @NonNull String xpath,
+        @NonNull FetchDescendantsOption fetchDescendantsOption);
+
 }
index 2a1e18b..26990de 100644 (file)
@@ -2,6 +2,7 @@
  * ============LICENSE_START=======================================================
  *  Copyright (C) 2021 Nordix Foundation
  *  Modifications Copyright (C) 2020 Bell Canada. All rights reserved.
+ *  Modifications Copyright (C) 2021 Pantheon.tech
  *  ================================================================================
  *  Licensed under the Apache License, Version 2.0 (the "License");
  *  you may not use this file except in compliance with the License.
@@ -24,6 +25,7 @@ import org.onap.cps.api.CpsAdminService;
 import org.onap.cps.api.CpsDataService;
 import org.onap.cps.api.CpsModuleService;
 import org.onap.cps.spi.CpsDataPersistenceService;
+import org.onap.cps.spi.FetchDescendantsOption;
 import org.onap.cps.spi.model.Anchor;
 import org.onap.cps.spi.model.DataNode;
 import org.onap.cps.spi.model.DataNodeBuilder;
@@ -60,4 +62,10 @@ public class CpsDataServiceImpl implements CpsDataService {
     private SchemaContext getSchemaContext(final String dataspaceName, final String schemaSetName) {
         return yangTextSchemaSourceSetCache.get(dataspaceName, schemaSetName).getSchemaContext();
     }
+
+    @Override
+    public DataNode getDataNode(final String dataspaceName, final String anchorName, final String xpath,
+        final FetchDescendantsOption fetchDescendantsOption) {
+        return cpsDataPersistenceService.getDataNode(dataspaceName, anchorName, xpath, fetchDescendantsOption);
+    }
 }
\ No newline at end of file
diff --git a/cps-service/src/main/java/org/onap/cps/spi/FetchChildrenOption.java b/cps-service/src/main/java/org/onap/cps/spi/FetchChildrenOption.java
new file mode 100644 (file)
index 0000000..97712c1
--- /dev/null
@@ -0,0 +1,25 @@
+/*
+ *  ============LICENSE_START=======================================================
+ *  Copyright (C) 2021 Pantheon.tech
+ *  ================================================================================
+ *  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.spi;
+
+public enum FetchChildrenOption {
+    OMIT_CHILDREN,
+    INCLUDE_ALL_CHILDREN
+}
diff --git a/cps-service/src/main/java/org/onap/cps/utils/DataMapUtils.java b/cps-service/src/main/java/org/onap/cps/utils/DataMapUtils.java
new file mode 100644 (file)
index 0000000..3ec4764
--- /dev/null
@@ -0,0 +1,98 @@
+/*
+ *  ============LICENSE_START=======================================================
+ *  Copyright (C) 2021 Pantheon.tech
+ *  ================================================================================
+ *  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;
+
+import static java.util.stream.Collectors.groupingBy;
+import static java.util.stream.Collectors.mapping;
+import static java.util.stream.Collectors.toUnmodifiableList;
+import static java.util.stream.Collectors.toUnmodifiableMap;
+
+import com.google.common.collect.ImmutableMap;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Map;
+import lombok.AccessLevel;
+import lombok.NoArgsConstructor;
+import org.onap.cps.spi.model.DataNode;
+
+/*
+ TODO: this utility class belongs to REST, however it expected to be used by both CPS Core and xNF Proxy;
+  placed in cps-service until shared module is done for REST services, then to be moved there
+  */
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+public class DataMapUtils {
+
+    /**
+     * Converts DataNode structure into a map for a JSON response.
+     *
+     * @param dataNode data node object
+     * @return a map representing same data
+     */
+
+    public static Map<String, Object> toDataMap(final DataNode dataNode) {
+        return ImmutableMap.<String, Object>builder()
+            .putAll(dataNode.getLeaves())
+            .putAll(listElementsAsMap(dataNode.getChildDataNodes()))
+            .putAll(containerElementsAsMap(dataNode.getChildDataNodes()))
+            .build();
+    }
+
+    private static Map<String, Object> listElementsAsMap(final Collection<DataNode> dataNodes) {
+        if (dataNodes.isEmpty()) {
+            return Collections.emptyMap();
+        }
+        return ImmutableMap.<String, Object>builder()
+            .putAll(
+                dataNodes.stream()
+                    .filter(dataNode -> isListNode(dataNode.getXpath()))
+                    .collect(groupingBy(
+                        dataNode -> getNodeIdentifier(dataNode.getXpath()),
+                        mapping(DataMapUtils::toDataMap, toUnmodifiableList())
+                    ))
+            ).build();
+    }
+
+    private static Map<String, Object> containerElementsAsMap(final Collection<DataNode> dataNodes) {
+        if (dataNodes.isEmpty()) {
+            return Collections.emptyMap();
+        }
+        return dataNodes.stream()
+            .filter(dataNode -> isContainerNode(dataNode.getXpath()))
+            .collect(
+                toUnmodifiableMap(
+                    dataNode -> getNodeIdentifier(dataNode.getXpath()),
+                    DataMapUtils::toDataMap
+                ));
+    }
+
+    private static String getNodeIdentifier(final String xpath) {
+        final int fromIndex = xpath.lastIndexOf("/") + 1;
+        final int toIndex = xpath.indexOf("[", fromIndex);
+        return toIndex > 0 ? xpath.substring(fromIndex, toIndex) : xpath.substring(fromIndex);
+    }
+
+    private static boolean isContainerNode(final String xpath) {
+        return !isListNode(xpath);
+    }
+
+    private static boolean isListNode(final String xpath) {
+        return xpath.endsWith("]");
+    }
+}
index 5874e27..65a0d54 100644 (file)
@@ -23,10 +23,13 @@ import org.onap.cps.TestUtils
 import org.onap.cps.api.CpsAdminService
 import org.onap.cps.api.CpsModuleService
 import org.onap.cps.spi.CpsDataPersistenceService
+import org.onap.cps.spi.FetchDescendantsOption
 import org.onap.cps.spi.model.Anchor
+import org.onap.cps.spi.model.DataNodeBuilder
 import org.onap.cps.yang.YangTextSchemaSourceSet
 import org.onap.cps.yang.YangTextSchemaSourceSetBuilder
 import spock.lang.Specification
+import spock.lang.Unroll
 
 class CpsDataServiceImplSpec extends Specification {
     def mockCpsDataPersistenceService = Mock(CpsDataPersistenceService)
@@ -37,10 +40,10 @@ class CpsDataServiceImplSpec extends Specification {
     def objectUnderTest = new CpsDataServiceImpl()
 
     def setup() {
-        objectUnderTest.cpsDataPersistenceService = mockCpsDataPersistenceService;
-        objectUnderTest.cpsAdminService = mockCpsAdminService;
-        objectUnderTest.cpsModuleService = mockCpsModuleService;
-        objectUnderTest.yangTextSchemaSourceSetCache = mockYangTextSchemaSourceSetCache;
+        objectUnderTest.cpsDataPersistenceService = mockCpsDataPersistenceService
+        objectUnderTest.cpsAdminService = mockCpsAdminService
+        objectUnderTest.cpsModuleService = mockCpsModuleService
+        objectUnderTest.yangTextSchemaSourceSetCache = mockYangTextSchemaSourceSetCache
     }
 
     def dataspaceName = 'some dataspace'
@@ -55,16 +58,28 @@ class CpsDataServiceImplSpec extends Specification {
             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
+            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: 'save data method is invoked with test-tree json data'
-            def jsonData = org.onap.cps.TestUtils.getResourceFileContent('test-tree.json')
+            def jsonData = TestUtils.getResourceFileContent('test-tree.json')
             objectUnderTest.saveData(dataspaceName, anchorName, jsonData)
         then: 'the persistence service method is invoked with correct parameters'
             1 * mockCpsDataPersistenceService.storeDataNode(dataspaceName, anchorName,
                     { dataNode -> dataNode.xpath == '/test-tree' })
     }
+
+    @Unroll
+    def 'Get data node with option #fetchChildrenOption'() {
+        def xpath = '/xpath'
+        def dataNode = new DataNodeBuilder().withXpath(xpath).build()
+        given: 'persistence service returns data for get data request'
+            mockCpsDataPersistenceService.getDataNode(dataspaceName, anchorName, xpath, fetchDescendantsOption) >> dataNode
+        expect: 'service returns same data if uses same parameters'
+            objectUnderTest.getDataNode(dataspaceName, anchorName, xpath, fetchDescendantsOption) == dataNode
+        where: 'all fetch options are supported'
+            fetchDescendantsOption << FetchDescendantsOption.values()
+    }
 }
\ No newline at end of file
diff --git a/cps-service/src/test/groovy/org/onap/cps/utils/DataMapUtilsSpec.groovy b/cps-service/src/test/groovy/org/onap/cps/utils/DataMapUtilsSpec.groovy
new file mode 100644 (file)
index 0000000..61cfc37
--- /dev/null
@@ -0,0 +1,74 @@
+/*
+ *  ============LICENSE_START=======================================================
+ *  Copyright (C) 2021 Pantheon.tech
+ *  ================================================================================
+ *  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
+
+import com.google.common.collect.ImmutableMap
+import org.onap.cps.spi.model.DataNode
+import org.onap.cps.spi.model.DataNodeBuilder
+import spock.lang.Specification
+
+import static java.util.Arrays.asList
+
+class DataMapUtilsSpec extends Specification {
+
+    DataNode dataNode = buildDataNode(
+            "/parent",
+            ImmutableMap.<String, Object> of("a", "b", "c", asList("d", "e")),
+            asList(
+                    buildDataNode(
+                            "/parent/child-list[@name='x']",
+                            ImmutableMap.<String, Object> of("name", "x"),
+                            Collections.emptyList()),
+                    buildDataNode(
+                            "/parent/child-list[@name='y']",
+                            ImmutableMap.<String, Object> of("name", "y"),
+                            Collections.emptyList()),
+                    buildDataNode(
+                            "/parent/child-object",
+                            ImmutableMap.<String, Object> of("m", "n"),
+                            asList(
+                                    buildDataNode(
+                                            "/parent/child-object/grand-child",
+                                            ImmutableMap.<String, Object> of("o", "p"),
+                                            Collections.emptyList()
+                                    )
+                            )
+                    ),
+            ))
+
+    static DataNode buildDataNode(String xpath, Map<String, Object> leaves, List<DataNode> children) {
+        return new DataNodeBuilder().withXpath(xpath).withLeaves(leaves).withChildDataNodes(children).build()
+    }
+
+    def 'Data node structure conversion to map.'() {
+        when: 'data node structure converted to map'
+            def result = DataMapUtils.toDataMap(dataNode)
+        then: 'root node leaves are top level elements'
+            assert result["a"] == "b"
+            assert ((Collection) result["c"]).containsAll("d", "e")
+        and: 'leaves of child list element are listed as structures under common identifier'
+            assert ((Collection) result["child-list"]).size() == 2
+            assert ((Collection) result["child-list"]).containsAll(["name": "x"], ["name": "y"])
+        and: 'leaves for child and grand-child elements are populated under their node identifiers'
+            assert result["child-object"]["m"] == "n"
+            assert result["child-object"]["grand-child"]["o"] == "p"
+    }
+
+}