diff options
18 files changed, 694 insertions, 113 deletions
diff --git a/cps-application/pom.xml b/cps-application/pom.xml index 9990cdd5a9..c689c75752 100755 --- a/cps-application/pom.xml +++ b/cps-application/pom.xml @@ -4,6 +4,7 @@ Copyright (c) 2021 Pantheon.tech. Modifications Copyright (C) 2021 Bell Canada. Modifications Copyright (C) 2021 Nordix Foundation + Modifications Copyright (C) 2022 Deutsche Telekom AG ================================================================================ Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -114,6 +115,10 @@ <groupId>com.tngtech.archunit</groupId> <artifactId>archunit-junit5</artifactId> </dependency> + <dependency> + <groupId>com.fasterxml.jackson.dataformat</groupId> + <artifactId>jackson-dataformat-xml</artifactId> + </dependency> </dependencies> <build> diff --git a/cps-rest/docs/openapi/components.yml b/cps-rest/docs/openapi/components.yml index 4f138fc898..e700da6ea1 100644 --- a/cps-rest/docs/openapi/components.yml +++ b/cps-rest/docs/openapi/components.yml @@ -2,6 +2,7 @@ # Copyright (c) 2021-2022 Bell Canada. # Modifications Copyright (C) 2021-2022 Nordix Foundation # Modifications Copyright (C) 2022 TechMahindra Ltd. +# Modifications Copyright (C) 2022 Deutsche Telekom AG # ================================================================================ # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -106,6 +107,17 @@ components: name: SciFi - code: 02 name: kids + dataSampleXml: + value: + <stores xmlns="urn:ietf:params:xml:ns:netconf:base:1.0"> + <bookstore xmlns="org:onap:ccsdk:sample"> + <bookstore-name>Chapters</bookstore-name> + <categories> + <code>1</code> + <name>SciFi</name> + </categories> + </bookstore> + </stores> parameters: dataspaceNameInQuery: @@ -220,6 +232,14 @@ components: type: string enum: [v1, v2] default: v2 + contentTypeHeader: + name: Content-Type + in: header + description: Content type header + schema: + type: string + example: 'application/json' + required: true responses: NotFound: diff --git a/cps-rest/docs/openapi/cpsData.yml b/cps-rest/docs/openapi/cpsData.yml index 9d940c3f83..0dc388706c 100644 --- a/cps-rest/docs/openapi/cpsData.yml +++ b/cps-rest/docs/openapi/cpsData.yml @@ -2,6 +2,7 @@ # Copyright (c) 2021-2022 Bell Canada. # Modifications Copyright (C) 2021-2022 Nordix Foundation # Modifications Copyright (C) 2022 TechMahindra Ltd. +# Modifications Copyright (C) 2022 Deutsche Telekom AG # ================================================================================ # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -130,15 +131,25 @@ nodesByDataspaceAndAnchor: - $ref: 'components.yml#/components/parameters/anchorNameInPath' - $ref: 'components.yml#/components/parameters/xpathInQuery' - $ref: 'components.yml#/components/parameters/observedTimestampInQuery' + - $ref: 'components.yml#/components/parameters/contentTypeHeader' requestBody: required: true content: application/json: schema: - type: object + type: string examples: dataSample: $ref: 'components.yml#/components/examples/dataSample' + application/xml: + schema: + type: object # Workaround to show example + xml: + name: stores + examples: + dataSample: + $ref: 'components.yml#/components/examples/dataSampleXml' + responses: '201': $ref: 'components.yml#/components/responses/Created' 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 c7d44b67b3..30bed12775 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 @@ -4,6 +4,7 @@ * Modifications Copyright (C) 2021 Pantheon.tech * Modifications Copyright (C) 2021-2022 Nordix Foundation * Modifications Copyright (C) 2022 TechMahindra Ltd. + * Modifications Copyright (C) 2022 Deutsche Telekom AG * ================================================================================ * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -32,11 +33,14 @@ 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.ContentType; import org.onap.cps.utils.DataMapUtils; import org.onap.cps.utils.JsonObjectMapper; import org.onap.cps.utils.PrefixResolver; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -54,16 +58,19 @@ public class DataRestController implements CpsDataApi { private final PrefixResolver prefixResolver; @Override - public ResponseEntity<String> createNode(final String apiVersion, - final String dataspaceName, final String anchorName, - final Object jsonData, final String parentNodeXpath, final String observedTimestamp) { - final String jsonDataAsString = jsonObjectMapper.asJsonString(jsonData); + public ResponseEntity<String> createNode(@RequestHeader(value = "Content-Type") final String contentTypeHeader, + final String apiVersion, + final String dataspaceName, final String anchorName, + final String nodeData, final String parentNodeXpath, + final String observedTimestamp) { + final ContentType contentType = contentTypeHeader.contains(MediaType.APPLICATION_XML_VALUE) ? ContentType.XML + : ContentType.JSON; if (isRootXpath(parentNodeXpath)) { - cpsDataService.saveData(dataspaceName, anchorName, jsonDataAsString, - toOffsetDateTime(observedTimestamp)); + cpsDataService.saveData(dataspaceName, anchorName, nodeData, + toOffsetDateTime(observedTimestamp), contentType); } else { cpsDataService.saveData(dataspaceName, anchorName, parentNodeXpath, - jsonDataAsString, toOffsetDateTime(observedTimestamp)); + nodeData, toOffsetDateTime(observedTimestamp), contentType); } return new ResponseEntity<>(HttpStatus.CREATED); } 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 53da3e6599..94f62f8c22 100755 --- 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 @@ -3,6 +3,7 @@ * Copyright (C) 2021-2022 Nordix Foundation * Modifications Copyright (C) 2021 Pantheon.tech * Modifications Copyright (C) 2021-2022 Bell Canada. + * Modifications Copyright (C) 2022 Deutsche Telekom AG * ================================================================================ * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -26,6 +27,7 @@ import com.fasterxml.jackson.databind.ObjectMapper import org.onap.cps.api.CpsDataService import org.onap.cps.spi.model.DataNode import org.onap.cps.spi.model.DataNodeBuilder +import org.onap.cps.utils.ContentType import org.onap.cps.utils.DateTimeUtility import org.onap.cps.utils.JsonObjectMapper import org.onap.cps.utils.PrefixResolver @@ -69,10 +71,20 @@ class DataRestControllerSpec extends Specification { def dataspaceName = 'my_dataspace' def anchorName = 'my_anchor' def noTimestamp = null - def requestBody = '{"some-key" : "some-value","categories":[{"books":[{"authors":["Iain M. Banks"]}]}]}' + + @Shared + def requestBodyJson = '{"some-key":"some-value","categories":[{"books":[{"authors":["Iain M. Banks"]}]}]}' + + @Shared def expectedJsonData = '{"some-key":"some-value","categories":[{"books":[{"authors":["Iain M. Banks"]}]}]}' @Shared + def requestBodyXml = '<?xml version=\'1.0\' encoding=\'UTF-8\'?>\n<bookstore xmlns="org:onap:ccsdk:sample">\n</bookstore>' + + @Shared + def expectedXmlData = '<?xml version=\'1.0\' encoding=\'UTF-8\'?>\n<bookstore xmlns="org:onap:ccsdk:sample">\n</bookstore>' + + @Shared static DataNode dataNodeWithLeavesNoChildren = new DataNodeBuilder().withXpath('/xpath') .withLeaves([leaf: 'value', leafList: ['leaveListElement1', 'leaveListElement2']]).build() @@ -91,18 +103,20 @@ class DataRestControllerSpec extends Specification { def response = mvc.perform( post(endpoint) - .contentType(MediaType.APPLICATION_JSON) + .contentType(contentType) .param('xpath', parentNodeXpath) .content(requestBody) ).andReturn().response then: 'a created response is returned' response.status == HttpStatus.CREATED.value() then: 'the java API was called with the correct parameters' - 1 * mockCpsDataService.saveData(dataspaceName, anchorName, expectedJsonData, noTimestamp) + 1 * mockCpsDataService.saveData(dataspaceName, anchorName, expectedData, noTimestamp, expectedContentType) where: 'following xpath parameters are are used' - scenario | parentNodeXpath - 'no xpath parameter' | '' - 'xpath parameter point root' | '/' + scenario | parentNodeXpath | contentType | expectedContentType | requestBody | expectedData + 'JSON content: no xpath parameter' | '' | MediaType.APPLICATION_JSON | ContentType.JSON | requestBodyJson | expectedJsonData + 'JSON content: xpath parameter point root' | '/' | MediaType.APPLICATION_JSON | ContentType.JSON | requestBodyJson | expectedJsonData + 'XML content: no xpath parameter' | '' | MediaType.APPLICATION_XML | ContentType.XML | requestBodyXml | expectedXmlData + 'XML content: xpath parameter point root' | '/' | MediaType.APPLICATION_XML | ContentType.XML | requestBodyXml | expectedXmlData } def 'Create a node with observed-timestamp'() { @@ -112,30 +126,31 @@ class DataRestControllerSpec extends Specification { def response = mvc.perform( post(endpoint) - .contentType(MediaType.APPLICATION_JSON) + .contentType(contentType) .param('xpath', '') .param('observed-timestamp', observedTimestamp) - .content(requestBody) + .content(content) ).andReturn().response then: 'a created response is returned' response.status == expectedHttpStatus.value() then: 'the java API was called with the correct parameters' - expectedApiCount * mockCpsDataService.saveData(dataspaceName, anchorName, expectedJsonData, - { it == DateTimeUtility.toOffsetDateTime(observedTimestamp) }) + expectedApiCount * mockCpsDataService.saveData(dataspaceName, anchorName, expectedData, + { it == DateTimeUtility.toOffsetDateTime(observedTimestamp) }, expectedContentType) where: - scenario | observedTimestamp || expectedApiCount | expectedHttpStatus - 'with observed-timestamp' | '2021-03-03T23:59:59.999-0400' || 1 | HttpStatus.CREATED - 'with invalid observed-timestamp' | 'invalid' || 0 | HttpStatus.BAD_REQUEST + scenario | observedTimestamp | contentType | content || expectedApiCount | expectedHttpStatus | expectedData | expectedContentType + 'with observed-timestamp JSON' | '2021-03-03T23:59:59.999-0400' | MediaType.APPLICATION_JSON | requestBodyJson || 1 | HttpStatus.CREATED | expectedJsonData | ContentType.JSON + 'with observed-timestamp XML' | '2021-03-03T23:59:59.999-0400' | MediaType.APPLICATION_XML | requestBodyXml || 1 | HttpStatus.CREATED | expectedXmlData | ContentType.XML + 'with invalid observed-timestamp' | 'invalid' | MediaType.APPLICATION_JSON | requestBodyJson || 0 | HttpStatus.BAD_REQUEST | expectedJsonData | ContentType.JSON } - def 'Create a child node'() { + def 'Create a child node #scenario'() { given: 'endpoint to create a node' def endpoint = "$dataNodeBaseEndpoint/anchors/$anchorName/nodes" and: 'parent node xpath' def parentNodeXpath = 'some xpath' when: 'post is invoked with datanode endpoint and json' def postRequestBuilder = post(endpoint) - .contentType(MediaType.APPLICATION_JSON) + .contentType(contentType) .param('xpath', parentNodeXpath) .content(requestBody) if (observedTimestamp != null) @@ -145,12 +160,14 @@ class DataRestControllerSpec extends Specification { then: 'a created response is returned' response.status == HttpStatus.CREATED.value() then: 'the java API was called with the correct parameters' - 1 * mockCpsDataService.saveData(dataspaceName, anchorName, parentNodeXpath, expectedJsonData, - DateTimeUtility.toOffsetDateTime(observedTimestamp)) + 1 * mockCpsDataService.saveData(dataspaceName, anchorName, parentNodeXpath, expectedData, + DateTimeUtility.toOffsetDateTime(observedTimestamp), expectedContentType) where: - scenario | observedTimestamp - 'with observed-timestamp' | '2021-03-03T23:59:59.999-0400' - 'without observed-timestamp' | null + scenario | observedTimestamp | contentType | requestBody | expectedData | expectedContentType + 'with observed-timestamp JSON' | '2021-03-03T23:59:59.999-0400' | MediaType.APPLICATION_JSON | requestBodyJson | expectedJsonData | ContentType.JSON + 'with observed-timestamp XML' | '2021-03-03T23:59:59.999-0400' | MediaType.APPLICATION_XML | requestBodyXml | expectedXmlData | ContentType.XML + 'without observed-timestamp JSON' | null | MediaType.APPLICATION_JSON | requestBodyJson | expectedJsonData | ContentType.JSON + 'without observed-timestamp XML' | null | MediaType.APPLICATION_XML | requestBodyXml | expectedXmlData | ContentType.XML } def 'Save list elements #scenario.'() { @@ -160,7 +177,7 @@ class DataRestControllerSpec extends Specification { def postRequestBuilder = post("$dataNodeBaseEndpoint/anchors/$anchorName/list-nodes") .contentType(MediaType.APPLICATION_JSON) .param('xpath', parentNodeXpath) - .content(requestBody) + .content(requestBodyJson) if (observedTimestamp != null) postRequestBuilder.param('observed-timestamp', observedTimestamp) def response = mvc.perform(postRequestBuilder).andReturn().response @@ -228,7 +245,7 @@ class DataRestControllerSpec extends Specification { mvc.perform( patch(endpoint) .contentType(MediaType.APPLICATION_JSON) - .content(requestBody) + .content(requestBodyJson) .param('xpath', inputXpath) ).andReturn().response then: 'the service method is invoked with expected parameters' @@ -250,7 +267,7 @@ class DataRestControllerSpec extends Specification { mvc.perform( patch(endpoint) .contentType(MediaType.APPLICATION_JSON) - .content(requestBody) + .content(requestBodyJson) .param('xpath', '/') .param('observed-timestamp', observedTimestamp) ).andReturn().response @@ -273,7 +290,7 @@ class DataRestControllerSpec extends Specification { mvc.perform( put(endpoint) .contentType(MediaType.APPLICATION_JSON) - .content(requestBody) + .content(requestBodyJson) .param('xpath', inputXpath)) .andReturn().response then: 'the service method is invoked with expected parameters' @@ -295,7 +312,7 @@ class DataRestControllerSpec extends Specification { mvc.perform( put(endpoint) .contentType(MediaType.APPLICATION_JSON) - .content(requestBody) + .content(requestBodyJson) .param('xpath', '') .param('observed-timestamp', observedTimestamp)) .andReturn().response @@ -315,7 +332,7 @@ class DataRestControllerSpec extends Specification { def putRequestBuilder = put("$dataNodeBaseEndpoint/anchors/$anchorName/list-nodes") .contentType(MediaType.APPLICATION_JSON) .param('xpath', 'parent xpath') - .content(requestBody) + .content(requestBodyJson) if (observedTimestamp != null) putRequestBuilder.param('observed-timestamp', observedTimestamp) def response = mvc.perform(putRequestBuilder).andReturn().response diff --git a/cps-rest/src/test/groovy/org/onap/cps/rest/exceptions/CpsRestExceptionHandlerSpec.groovy b/cps-rest/src/test/groovy/org/onap/cps/rest/exceptions/CpsRestExceptionHandlerSpec.groovy index ece3507f2d..0821b6bebc 100644 --- a/cps-rest/src/test/groovy/org/onap/cps/rest/exceptions/CpsRestExceptionHandlerSpec.groovy +++ b/cps-rest/src/test/groovy/org/onap/cps/rest/exceptions/CpsRestExceptionHandlerSpec.groovy @@ -4,6 +4,7 @@ * Modifications Copyright (C) 2021-2022 Nordix Foundation * Modifications Copyright (C) 2021 Bell Canada. * Modifications Copyright (C) 2022 TechMahindra Ltd. + * Modifications Copyright (C) 2022 Deutsche Telekom AG * ================================================================================ * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -167,13 +168,13 @@ class CpsRestExceptionHandlerSpec extends Specification { def 'Post request with #exceptionThrown.class.simpleName returns HTTP Status Bad Request.'() { given: '#exception is thrown the service indicating data is not found' - mockCpsDataService.saveData(_, _, _, _, _) >> { throw exceptionThrown } + mockCpsDataService.saveData(*_) >> { throw exceptionThrown } when: 'data update request is performed' def response = mvc.perform( post("$basePath/v1/dataspaces/dataspace-name/anchors/anchor-name/nodes") .contentType(MediaType.APPLICATION_JSON) .param('xpath', 'parent node xpath') - .content(groovy.json.JsonOutput.toJson('{"some-key" : "some-value"}')) + .content('{"some-key" : "some-value"}') ).andReturn().response then: 'response code indicates bad input parameters' response.status == BAD_REQUEST.value() @@ -181,18 +182,6 @@ class CpsRestExceptionHandlerSpec extends Specification { exceptionThrown << [new DataNodeNotFoundException('', ''), new NotFoundInDataspaceException('', '')] } - def 'Post request with invalid JSON payload returns HTTP Status Bad Request.'() { - when: 'data post request is performed' - def response = mvc.perform( - post("$basePath/v1/dataspaces/dataspace-name/anchors/anchor-name/nodes") - .contentType(MediaType.APPLICATION_JSON) - .param('xpath', 'parent node xpath') - .content('{') - ).andReturn().response - then: 'response code indicates bad input parameters' - response.status == BAD_REQUEST.value() - } - /* * NB. The test uses 'get anchors' endpoint and associated service method invocation * to test the exception handling. The endpoint chosen is not a subject of test. diff --git a/cps-service/pom.xml b/cps-service/pom.xml index 77f262c32e..7173d8674b 100644 --- a/cps-service/pom.xml +++ b/cps-service/pom.xml @@ -4,6 +4,7 @@ Copyright (C) 2021-2022 Nordix Foundation Modifications Copyright (C) 2021 Bell Canada. Modifications Copyright (C) 2021 Pantheon.tech + Modifications Copyright (C) 2022 Deutsche Telekom AG ================================================================================ Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -65,6 +66,10 @@ <artifactId>yang-data-codec-gson</artifactId> </dependency> <dependency> + <groupId>org.opendaylight.yangtools</groupId> + <artifactId>yang-data-codec-xml</artifactId> + </dependency> + <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </dependency> 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 b2e8c5ba42..012d7f8259 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 @@ -3,6 +3,7 @@ * Copyright (C) 2020-2022 Nordix Foundation * Modifications Copyright (C) 2021 Pantheon.tech * Modifications Copyright (C) 2021-2022 Bell Canada + * Modifications Copyright (C) 2022 Deutsche Telekom AG * ================================================================================ * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -27,6 +28,7 @@ import java.util.Collection; import java.util.Map; import org.onap.cps.spi.FetchDescendantsOption; import org.onap.cps.spi.model.DataNode; +import org.onap.cps.utils.ContentType; /* * Datastore interface for handling CPS data. @@ -38,10 +40,22 @@ public interface CpsDataService { * * @param dataspaceName dataspace name * @param anchorName anchor name - * @param jsonData json data + * @param nodeData node data * @param observedTimestamp observedTimestamp */ - void saveData(String dataspaceName, String anchorName, String jsonData, OffsetDateTime observedTimestamp); + void saveData(String dataspaceName, String anchorName, String nodeData, OffsetDateTime observedTimestamp); + + /** + * Persists data for the given anchor and dataspace. + * + * @param dataspaceName dataspace name + * @param anchorName anchor name + * @param nodeData node data + * @param observedTimestamp observedTimestamp + * @param contentType node data content type + */ + void saveData(String dataspaceName, String anchorName, String nodeData, OffsetDateTime observedTimestamp, + ContentType contentType); /** * Persists child data fragment under existing data node for the given anchor and dataspace. @@ -49,11 +63,25 @@ public interface CpsDataService { * @param dataspaceName dataspace name * @param anchorName anchor name * @param parentNodeXpath parent node xpath - * @param jsonData json data + * @param nodeData node data * @param observedTimestamp observedTimestamp */ - void saveData(String dataspaceName, String anchorName, String parentNodeXpath, String jsonData, - OffsetDateTime observedTimestamp); + void saveData(String dataspaceName, String anchorName, String parentNodeXpath, String nodeData, + OffsetDateTime observedTimestamp); + + /** + * Persists child data fragment under existing data node for the given anchor, dataspace and content type. + * + * @param dataspaceName dataspace name + * @param anchorName anchor name + * @param parentNodeXpath parent node xpath + * @param nodeData node data + * @param observedTimestamp observedTimestamp + * @param contentType node data content type + * + */ + void saveData(String dataspaceName, String anchorName, String parentNodeXpath, String nodeData, + OffsetDateTime observedTimestamp, ContentType contentType); /** * Persists child data fragment representing one or more list elements under existing data node for the 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 732b494994..c776e5bb31 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 @@ -4,6 +4,7 @@ * Modifications Copyright (C) 2020-2022 Bell Canada. * Modifications Copyright (C) 2021 Pantheon.tech * Modifications Copyright (C) 2022 TechMahindra Ltd. + * Modifications Copyright (C) 2022 Deutsche Telekom AG * ================================================================================ * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -46,6 +47,7 @@ import org.onap.cps.spi.model.Anchor; import org.onap.cps.spi.model.DataNode; import org.onap.cps.spi.model.DataNodeBuilder; import org.onap.cps.spi.utils.CpsValidator; +import org.onap.cps.utils.ContentType; import org.onap.cps.utils.YangUtils; import org.opendaylight.yangtools.yang.data.api.schema.ContainerNode; import org.opendaylight.yangtools.yang.model.api.SchemaContext; @@ -66,21 +68,34 @@ public class CpsDataServiceImpl implements CpsDataService { private final CpsValidator cpsValidator; @Override - public void saveData(final String dataspaceName, final String anchorName, final String jsonData, + public void saveData(final String dataspaceName, final String anchorName, final String nodeData, final OffsetDateTime observedTimestamp) { + saveData(dataspaceName, anchorName, nodeData, observedTimestamp, ContentType.JSON); + } + + @Override + public void saveData(final String dataspaceName, final String anchorName, final String nodeData, + final OffsetDateTime observedTimestamp, final ContentType contentType) { cpsValidator.validateNameCharacters(dataspaceName, anchorName); final Collection<DataNode> dataNodes = - buildDataNodes(dataspaceName, anchorName, ROOT_NODE_XPATH, jsonData); + buildDataNodes(dataspaceName, anchorName, ROOT_NODE_XPATH, nodeData, contentType); cpsDataPersistenceService.storeDataNodes(dataspaceName, anchorName, dataNodes); processDataUpdatedEventAsync(dataspaceName, anchorName, ROOT_NODE_XPATH, CREATE, observedTimestamp); } @Override public void saveData(final String dataspaceName, final String anchorName, final String parentNodeXpath, - final String jsonData, final OffsetDateTime observedTimestamp) { + final String nodeData, final OffsetDateTime observedTimestamp) { + saveData(dataspaceName, anchorName, parentNodeXpath, nodeData, observedTimestamp, ContentType.JSON); + } + + @Override + public void saveData(final String dataspaceName, final String anchorName, final String parentNodeXpath, + final String nodeData, final OffsetDateTime observedTimestamp, + final ContentType contentType) { cpsValidator.validateNameCharacters(dataspaceName, anchorName); final Collection<DataNode> dataNodes = - buildDataNodes(dataspaceName, anchorName, parentNodeXpath, jsonData); + buildDataNodes(dataspaceName, anchorName, parentNodeXpath, nodeData, contentType); cpsDataPersistenceService.addChildDataNodes(dataspaceName, anchorName, parentNodeXpath, dataNodes); processDataUpdatedEventAsync(dataspaceName, anchorName, parentNodeXpath, CREATE, observedTimestamp); } @@ -90,7 +105,7 @@ public class CpsDataServiceImpl implements CpsDataService { final String parentNodeXpath, final String jsonData, final OffsetDateTime observedTimestamp) { cpsValidator.validateNameCharacters(dataspaceName, anchorName); final Collection<DataNode> listElementDataNodeCollection = - buildDataNodes(dataspaceName, anchorName, parentNodeXpath, jsonData); + buildDataNodes(dataspaceName, anchorName, parentNodeXpath, jsonData, ContentType.JSON); cpsDataPersistenceService.addListElements(dataspaceName, anchorName, parentNodeXpath, listElementDataNodeCollection); processDataUpdatedEventAsync(dataspaceName, anchorName, parentNodeXpath, UPDATE, observedTimestamp); @@ -101,7 +116,7 @@ public class CpsDataServiceImpl implements CpsDataService { final Collection<String> jsonDataList, final OffsetDateTime observedTimestamp) { cpsValidator.validateNameCharacters(dataspaceName, anchorName); final Collection<Collection<DataNode>> listElementDataNodeCollections = - buildDataNodes(dataspaceName, anchorName, parentNodeXpath, jsonDataList); + buildDataNodes(dataspaceName, anchorName, parentNodeXpath, jsonDataList, ContentType.JSON); cpsDataPersistenceService.addMultipleLists(dataspaceName, anchorName, parentNodeXpath, listElementDataNodeCollections); processDataUpdatedEventAsync(dataspaceName, anchorName, parentNodeXpath, UPDATE, observedTimestamp); @@ -118,7 +133,7 @@ public class CpsDataServiceImpl implements CpsDataService { public void updateNodeLeaves(final String dataspaceName, final String anchorName, final String parentNodeXpath, final String jsonData, final OffsetDateTime observedTimestamp) { cpsValidator.validateNameCharacters(dataspaceName, anchorName); - final DataNode dataNode = buildDataNode(dataspaceName, anchorName, parentNodeXpath, jsonData); + final DataNode dataNode = buildDataNode(dataspaceName, anchorName, parentNodeXpath, jsonData, ContentType.JSON); cpsDataPersistenceService .updateDataLeaves(dataspaceName, anchorName, dataNode.getXpath(), dataNode.getLeaves()); processDataUpdatedEventAsync(dataspaceName, anchorName, parentNodeXpath, UPDATE, observedTimestamp); @@ -132,7 +147,7 @@ public class CpsDataServiceImpl implements CpsDataService { cpsValidator.validateNameCharacters(dataspaceName, anchorName); final Collection<DataNode> dataNodeUpdates = buildDataNodes(dataspaceName, anchorName, - parentNodeXpath, dataNodeUpdatesAsJson); + parentNodeXpath, dataNodeUpdatesAsJson, ContentType.JSON); for (final DataNode dataNodeUpdate : dataNodeUpdates) { processDataNodeUpdate(dataspaceName, anchorName, dataNodeUpdate); } @@ -166,7 +181,7 @@ public class CpsDataServiceImpl implements CpsDataService { final OffsetDateTime observedTimestamp) { cpsValidator.validateNameCharacters(dataspaceName, anchorName); final Collection<DataNode> dataNodes = - buildDataNodes(dataspaceName, anchorName, parentNodeXpath, jsonData); + buildDataNodes(dataspaceName, anchorName, parentNodeXpath, jsonData, ContentType.JSON); final ArrayList<DataNode> nodes = new ArrayList<>(dataNodes); cpsDataPersistenceService.updateDataNodesAndDescendants(dataspaceName, anchorName, nodes); processDataUpdatedEventAsync(dataspaceName, anchorName, parentNodeXpath, UPDATE, observedTimestamp); @@ -189,7 +204,7 @@ public class CpsDataServiceImpl implements CpsDataService { final String jsonData, final OffsetDateTime observedTimestamp) { cpsValidator.validateNameCharacters(dataspaceName, anchorName); final Collection<DataNode> newListElements = - buildDataNodes(dataspaceName, anchorName, parentNodeXpath, jsonData); + buildDataNodes(dataspaceName, anchorName, parentNodeXpath, jsonData, ContentType.JSON); replaceListContent(dataspaceName, anchorName, parentNodeXpath, newListElements, observedTimestamp); } @@ -226,18 +241,20 @@ public class CpsDataServiceImpl implements CpsDataService { } private DataNode buildDataNode(final String dataspaceName, final String anchorName, - final String parentNodeXpath, final String jsonData) { + final String parentNodeXpath, final String nodeData, + final ContentType contentType) { final Anchor anchor = cpsAdminService.getAnchor(dataspaceName, anchorName); final SchemaContext schemaContext = getSchemaContext(dataspaceName, anchor.getSchemaSetName()); if (ROOT_NODE_XPATH.equals(parentNodeXpath)) { - final ContainerNode containerNode = YangUtils.parseJsonData(jsonData, schemaContext); + final ContainerNode containerNode = YangUtils.parseData(contentType, nodeData, schemaContext); return new DataNodeBuilder().withContainerNode(containerNode).build(); } final ContainerNode containerNode = YangUtils - .parseJsonData(jsonData, schemaContext, parentNodeXpath); + .parseData(contentType, nodeData, schemaContext, parentNodeXpath); + return new DataNodeBuilder() .withParentNodeXpath(parentNodeXpath) .withContainerNode(containerNode) @@ -248,18 +265,19 @@ public class CpsDataServiceImpl implements CpsDataService { final Map<String, String> nodesJsonData) { return nodesJsonData.entrySet().stream().map(nodeJsonData -> buildDataNode(dataspaceName, anchorName, nodeJsonData.getKey(), - nodeJsonData.getValue())).collect(Collectors.toList()); + nodeJsonData.getValue(), ContentType.JSON)).collect(Collectors.toList()); } private Collection<DataNode> buildDataNodes(final String dataspaceName, final String anchorName, final String parentNodeXpath, - final String jsonData) { + final String nodeData, + final ContentType contentType) { final Anchor anchor = cpsAdminService.getAnchor(dataspaceName, anchorName); final SchemaContext schemaContext = getSchemaContext(dataspaceName, anchor.getSchemaSetName()); if (ROOT_NODE_XPATH.equals(parentNodeXpath)) { - final ContainerNode containerNode = YangUtils.parseJsonData(jsonData, schemaContext); + final ContainerNode containerNode = YangUtils.parseData(contentType, nodeData, schemaContext); final Collection<DataNode> dataNodes = new DataNodeBuilder() .withContainerNode(containerNode) .buildCollection(); @@ -268,7 +286,7 @@ public class CpsDataServiceImpl implements CpsDataService { } return dataNodes; } - final ContainerNode containerNode = YangUtils.parseJsonData(jsonData, schemaContext, parentNodeXpath); + final ContainerNode containerNode = YangUtils.parseData(contentType, nodeData, schemaContext, parentNodeXpath); final Collection<DataNode> dataNodes = new DataNodeBuilder() .withParentNodeXpath(parentNodeXpath) .withContainerNode(containerNode) @@ -281,9 +299,9 @@ public class CpsDataServiceImpl implements CpsDataService { } private Collection<Collection<DataNode>> buildDataNodes(final String dataspaceName, final String anchorName, - final String parentNodeXpath, final Collection<String> jsonDataList) { - return jsonDataList.stream() - .map(jsonData -> buildDataNodes(dataspaceName, anchorName, parentNodeXpath, jsonData)) + final String parentNodeXpath, final Collection<String> nodeDataList, final ContentType contentType) { + return nodeDataList.stream() + .map(nodeData -> buildDataNodes(dataspaceName, anchorName, parentNodeXpath, nodeData, contentType)) .collect(Collectors.toList()); } diff --git a/cps-service/src/main/java/org/onap/cps/utils/ContentType.java b/cps-service/src/main/java/org/onap/cps/utils/ContentType.java new file mode 100644 index 0000000000..f888504843 --- /dev/null +++ b/cps-service/src/main/java/org/onap/cps/utils/ContentType.java @@ -0,0 +1,26 @@ +/* + * ============LICENSE_START======================================================= + * Copyright (C) 2022 Deutsche Telekom AG + * ================================================================================ + * 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; + +public enum ContentType { + JSON, + XML +} diff --git a/cps-service/src/main/java/org/onap/cps/utils/XmlFileUtils.java b/cps-service/src/main/java/org/onap/cps/utils/XmlFileUtils.java new file mode 100644 index 0000000000..0946ae3f64 --- /dev/null +++ b/cps-service/src/main/java/org/onap/cps/utils/XmlFileUtils.java @@ -0,0 +1,165 @@ +/* + * ============LICENSE_START======================================================= + * Copyright (C) 2022 Deutsche Telekom AG + * ================================================================================ + * 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 java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.StringWriter; +import java.util.HashMap; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.transform.Transformer; +import javax.xml.transform.TransformerException; +import javax.xml.transform.TransformerFactory; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; +import org.onap.cps.spi.exceptions.DataValidationException; +import org.opendaylight.yangtools.yang.model.api.DataSchemaNode; +import org.opendaylight.yangtools.yang.model.api.SchemaContext; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.xml.sax.SAXException; + +public class XmlFileUtils { + + private static DocumentBuilderFactory dbFactory = DocumentBuilderFactory.newInstance(); + private static final String DATA_ROOT_NODE_TAG_NAME = "data"; + private static final String ROOT_NODE_NAMESPACE = "urn:ietf:params:xml:ns:netconf:base:1.0"; + private static final Pattern XPATH_PROPERTY_REGEX = Pattern.compile( + "\\[@(\\S{1,100})=['\\\"](\\S{1,100})['\\\"]\\]"); + + /** + * Prepare XML content. + * + * @param xmlContent XML content sent to store + * @param schemaContext schema context + * @return XML content wrapped by root node (if needed) + */ + public static String prepareXmlContent(final String xmlContent, final SchemaContext schemaContext) { + + return addRootNodeToXmlContent(xmlContent, schemaContext.getModules().iterator().next().getName(), + ROOT_NODE_NAMESPACE); + + } + + /** + * Prepare XML content. + * + * @param xmlContent XML content sent to store + * @param parentSchemaNode Parent schema node + * @return XML content wrapped by root node (if needed) + */ + public static String prepareXmlContent(final String xmlContent, final DataSchemaNode parentSchemaNode, + final String xpath) { + final String namespace = parentSchemaNode.getQName().getNamespace().toString(); + final String parentXpathPart = xpath.substring(xpath.lastIndexOf('/') + 1); + final Matcher regexMatcher = XPATH_PROPERTY_REGEX.matcher(parentXpathPart); + if (regexMatcher.find()) { + final HashMap<String, String> rootNodePropertyMap = new HashMap<String, String>(); + rootNodePropertyMap.put(regexMatcher.group(1), regexMatcher.group(2)); + return addRootNodeToXmlContent(xmlContent, parentSchemaNode.getQName().getLocalName(), namespace, + rootNodePropertyMap); + } + + return addRootNodeToXmlContent(xmlContent, parentSchemaNode.getQName().getLocalName(), namespace); + } + + /** + * Add root node to XML content. + * + * @param xmlContent xml content to add root node + * @param rootNodeTagName root node tag name + * @param namespace root node namespace + * @param rootNodeProperty root node properites map + * @return An edited content with added root node (if needed) + */ + public static String addRootNodeToXmlContent(final String xmlContent, final String rootNodeTagName, + final String namespace, + final HashMap<String, String> rootNodeProperty) { + try { + final DocumentBuilder documentBuilder = dbFactory.newDocumentBuilder(); + final StringBuilder xmlStringBuilder = new StringBuilder(); + xmlStringBuilder.append(xmlContent); + Document xmlDoc = documentBuilder.parse( + new ByteArrayInputStream(xmlStringBuilder.toString().getBytes("utf-8"))); + final Element root = xmlDoc.getDocumentElement(); + if (!root.getTagName().equals(rootNodeTagName) && !root.getTagName().equals(DATA_ROOT_NODE_TAG_NAME)) { + xmlDoc = addDataRootNode(root, rootNodeTagName, namespace, rootNodeProperty); + xmlDoc.setXmlStandalone(true); + final TransformerFactory transformerFactory = TransformerFactory.newInstance(); + transformerFactory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, ""); + transformerFactory.setAttribute(XMLConstants.ACCESS_EXTERNAL_STYLESHEET, ""); + final Transformer transformer = transformerFactory.newTransformer(); + final StringWriter stringWriter = new StringWriter(); + transformer.transform(new DOMSource(xmlDoc), new StreamResult(stringWriter)); + return stringWriter.toString(); + } + return xmlContent; + } catch (SAXException | IOException | ParserConfigurationException | TransformerException exception) { + throw new DataValidationException("Failed to parse XML data", "Invalid xml input " + exception.getMessage(), + exception); + } + } + + /** + * Add root node to XML content. + * + * @param xmlContent XML content to add root node into + * @param rootNodeTagName Root node tag name + * @return XML content with root node tag added (if needed) + */ + public static String addRootNodeToXmlContent(final String xmlContent, final String rootNodeTagName, + final String namespace) { + return addRootNodeToXmlContent(xmlContent, rootNodeTagName, namespace, new HashMap<String, String>()); + } + + /** + * Add root node into DOM element. + * + * @param node DOM element to add root node into + * @param tagName Root tag name to add + * @return DOM element with a root node + */ + static Document addDataRootNode(final Element node, final String tagName, final String namespace, + final HashMap<String, String> rootNodeProperty) { + try { + final DocumentBuilder docBuilder = dbFactory.newDocumentBuilder(); + final Document document = docBuilder.newDocument(); + final Element rootElement = document.createElementNS(namespace, tagName); + for (final Map.Entry<String, String> entry : rootNodeProperty.entrySet()) { + final Element propertyElement = document.createElement(entry.getKey()); + propertyElement.setTextContent(entry.getValue()); + rootElement.appendChild(propertyElement); + } + rootElement.appendChild(document.adoptNode(node)); + document.appendChild(rootElement); + return document; + } catch (final ParserConfigurationException exception) { + throw new DataValidationException("Can't parse XML", "XML can't be parsed", exception); + } + } +} diff --git a/cps-service/src/main/java/org/onap/cps/utils/YangUtils.java b/cps-service/src/main/java/org/onap/cps/utils/YangUtils.java index 9a61579b12..49d3a70f9f 100644 --- a/cps-service/src/main/java/org/onap/cps/utils/YangUtils.java +++ b/cps-service/src/main/java/org/onap/cps/utils/YangUtils.java @@ -4,6 +4,7 @@ * Modifications Copyright (C) 2021 Bell Canada. * Modifications Copyright (C) 2021 Pantheon.tech * Modifications Copyright (C) 2022 TechMahindra Ltd. + * Modifications Copyright (C) 2022 Deutsche Telekom AG * ================================================================================ * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -27,13 +28,20 @@ import com.google.gson.JsonSyntaxException; import com.google.gson.stream.JsonReader; import java.io.IOException; import java.io.StringReader; +import java.net.URISyntaxException; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.stream.Collectors; +import javax.xml.stream.XMLInputFactory; +import javax.xml.stream.XMLStreamException; +import javax.xml.stream.XMLStreamReader; import lombok.AccessLevel; import lombok.NoArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -41,13 +49,18 @@ import org.onap.cps.spi.exceptions.DataValidationException; import org.opendaylight.yangtools.yang.common.QName; import org.opendaylight.yangtools.yang.data.api.YangInstanceIdentifier; import org.opendaylight.yangtools.yang.data.api.schema.ContainerNode; +import org.opendaylight.yangtools.yang.data.api.schema.DataContainerChild; +import org.opendaylight.yangtools.yang.data.api.schema.LeafNode; +import org.opendaylight.yangtools.yang.data.api.schema.NormalizedNode; import org.opendaylight.yangtools.yang.data.api.schema.builder.DataContainerNodeBuilder; import org.opendaylight.yangtools.yang.data.api.schema.stream.NormalizedNodeStreamWriter; import org.opendaylight.yangtools.yang.data.codec.gson.JSONCodecFactory; import org.opendaylight.yangtools.yang.data.codec.gson.JSONCodecFactorySupplier; import org.opendaylight.yangtools.yang.data.codec.gson.JsonParserStream; +import org.opendaylight.yangtools.yang.data.codec.xml.XmlParserStream; import org.opendaylight.yangtools.yang.data.impl.schema.Builders; import org.opendaylight.yangtools.yang.data.impl.schema.ImmutableNormalizedNodeStreamWriter; +import org.opendaylight.yangtools.yang.data.impl.schema.NormalizedNodeResult; import org.opendaylight.yangtools.yang.model.api.DataNodeContainer; import org.opendaylight.yangtools.yang.model.api.DataSchemaNode; import org.opendaylight.yangtools.yang.model.api.EffectiveModelContext; @@ -55,6 +68,7 @@ import org.opendaylight.yangtools.yang.model.api.EffectiveStatementInference; import org.opendaylight.yangtools.yang.model.api.SchemaContext; import org.opendaylight.yangtools.yang.model.api.stmt.SchemaNodeIdentifier; import org.opendaylight.yangtools.yang.model.util.SchemaInferenceStack; +import org.xml.sax.SAXException; @Slf4j @NoArgsConstructor(access = AccessLevel.PRIVATE) @@ -64,7 +78,45 @@ public class YangUtils { private static final String XPATH_NODE_KEY_ATTRIBUTES_REGEX = "\\[.*?\\]"; /** - * Parses jsonData into Collection of NormalizedNode according to given schema context. + * Parses data into Collection of NormalizedNode according to given schema context. + * + * @param nodeData data string + * @param schemaContext schema context describing associated data model + * @return the NormalizedNode object + */ + public static ContainerNode parseData(final ContentType contentType, final String nodeData, + final SchemaContext schemaContext) { + if (contentType == ContentType.JSON) { + return parseJsonData(nodeData, schemaContext, Optional.empty()); + } + return parseXmlData(XmlFileUtils.prepareXmlContent(nodeData, schemaContext), schemaContext, + Optional.empty()); + } + + /** + * Parses data into NormalizedNode according to given schema context. + * + * @param nodeData data string + * @param schemaContext schema context describing associated data model + * @return the NormalizedNode object + */ + public static ContainerNode parseData(final ContentType contentType, final String nodeData, + final SchemaContext schemaContext, final String parentNodeXpath) { + final DataSchemaNode parentSchemaNode = + (DataSchemaNode) getDataSchemaNodeAndIdentifiersByXpath(parentNodeXpath, schemaContext) + .get("dataSchemaNode"); + final Collection<QName> dataSchemaNodeIdentifiers = + (Collection<QName>) getDataSchemaNodeAndIdentifiersByXpath(parentNodeXpath, schemaContext) + .get("dataSchemaNodeIdentifiers"); + if (contentType == ContentType.JSON) { + return parseJsonData(nodeData, schemaContext, Optional.of(dataSchemaNodeIdentifiers)); + } + return parseXmlData(XmlFileUtils.prepareXmlContent(nodeData, parentSchemaNode, parentNodeXpath), schemaContext, + Optional.of(dataSchemaNodeIdentifiers)); + } + + /** + * Parses data into Collection of NormalizedNode according to given schema context. * * @param jsonData json data as string * @param schemaContext schema context describing associated data model @@ -85,7 +137,8 @@ public class YangUtils { public static ContainerNode parseJsonData(final String jsonData, final SchemaContext schemaContext, final String parentNodeXpath) { final Collection<QName> dataSchemaNodeIdentifiers = - getDataSchemaNodeIdentifiersByXpath(parentNodeXpath, schemaContext); + (Collection<QName>) getDataSchemaNodeAndIdentifiersByXpath(parentNodeXpath, schemaContext) + .get("dataSchemaNodeIdentifiers"); return parseJsonData(jsonData, schemaContext, Optional.of(dataSchemaNodeIdentifiers)); } @@ -105,7 +158,7 @@ public class YangUtils { final EffectiveModelContext effectiveModelContext = ((EffectiveModelContext) schemaContext); final EffectiveStatementInference effectiveStatementInference = SchemaInferenceStack.of(effectiveModelContext, - SchemaNodeIdentifier.Absolute.of(dataSchemaNodeIdentifiers.get())).toInference(); + SchemaNodeIdentifier.Absolute.of(dataSchemaNodeIdentifiers.get())).toInference(); jsonParserStream = JsonParserStream.create(normalizedNodeStreamWriter, jsonCodecFactory, effectiveStatementInference); } else { @@ -115,22 +168,56 @@ public class YangUtils { try { jsonParserStream.parse(jsonReader); jsonParserStream.close(); - } catch (final JsonSyntaxException exception) { + } catch (final IOException | JsonSyntaxException exception) { throw new DataValidationException( - "Failed to parse json data: " + jsonData, exception.getMessage(), exception); - } catch (final IOException | IllegalStateException illegalStateException) { + "Failed to parse json data: " + jsonData, exception.getMessage(), exception); + } catch (final IllegalStateException | IllegalArgumentException exception) { throw new DataValidationException( - "Failed to parse json data. Unsupported xpath or json data:" + jsonData, illegalStateException - .getMessage(), illegalStateException); + "Failed to parse json data. Unsupported xpath or json data:" + jsonData, exception + .getMessage(), exception); } return dataContainerNodeBuilder.build(); } + private static ContainerNode parseXmlData(final String xmlData, final SchemaContext schemaContext, + final Optional<Collection<QName>> dataSchemaNodeIdentifiers) { + final XMLInputFactory factory = XMLInputFactory.newInstance(); + factory.setProperty(XMLInputFactory.SUPPORT_DTD, false); + final NormalizedNodeResult normalizedNodeResult = new NormalizedNodeResult(); + final NormalizedNodeStreamWriter normalizedNodeStreamWriter = ImmutableNormalizedNodeStreamWriter + .from(normalizedNodeResult); + + final XmlParserStream xmlParser; + final EffectiveModelContext effectiveModelContext = ((EffectiveModelContext) schemaContext); + + if (dataSchemaNodeIdentifiers.isPresent()) { + final EffectiveStatementInference effectiveStatementInference = + SchemaInferenceStack.of(effectiveModelContext, + SchemaNodeIdentifier.Absolute.of(dataSchemaNodeIdentifiers.get())).toInference(); + xmlParser = XmlParserStream.create(normalizedNodeStreamWriter, effectiveStatementInference); + } else { + xmlParser = XmlParserStream.create(normalizedNodeStreamWriter, effectiveModelContext); + } + + try { + final XMLStreamReader reader = factory.createXMLStreamReader(new StringReader(xmlData)); + xmlParser.parse(reader); + xmlParser.close(); + } catch (final XMLStreamException | URISyntaxException | IOException + | SAXException | NullPointerException exception) { + throw new DataValidationException( + "Failed to parse xml data: " + xmlData, exception.getMessage(), exception); + } + final NormalizedNode normalizedNode = getFirstChildXmlRoot(normalizedNodeResult.getResult()); + return Builders.containerBuilder().withChild((DataContainerChild) normalizedNode) + .withNodeIdentifier(new YangInstanceIdentifier.NodeIdentifier(schemaContext.getQName())).build(); + } + /** * Create an xpath form a Yang Tools NodeIdentifier (i.e. PathArgument). * * @param nodeIdentifier the NodeIdentifier - * @return an xpath + * @return a xpath */ public static String buildXpath(final YangInstanceIdentifier.PathArgument nodeIdentifier) { final StringBuilder xpathBuilder = new StringBuilder(); @@ -138,20 +225,20 @@ public class YangUtils { if (nodeIdentifier instanceof YangInstanceIdentifier.NodeIdentifierWithPredicates) { xpathBuilder.append(getKeyAttributesStatement( - (YangInstanceIdentifier.NodeIdentifierWithPredicates) nodeIdentifier)); + (YangInstanceIdentifier.NodeIdentifierWithPredicates) nodeIdentifier)); } return xpathBuilder.toString(); } private static String getKeyAttributesStatement( - final YangInstanceIdentifier.NodeIdentifierWithPredicates nodeIdentifier) { + final YangInstanceIdentifier.NodeIdentifierWithPredicates nodeIdentifier) { final List<String> keyAttributes = nodeIdentifier.entrySet().stream().map( - entry -> { - final String name = entry.getKey().getLocalName(); - final String value = String.valueOf(entry.getValue()).replace("'", "\\'"); - return String.format("@%s='%s'", name, value); - } + entry -> { + final String name = entry.getKey().getLocalName(); + final String value = String.valueOf(entry.getValue()).replace("'", "\\'"); + return String.format("@%s='%s'", name, value); + } ).collect(Collectors.toList()); if (keyAttributes.isEmpty()) { @@ -162,10 +249,10 @@ public class YangUtils { } } - private static Collection<QName> getDataSchemaNodeIdentifiersByXpath(final String parentNodeXpath, - final SchemaContext schemaContext) { + private static Map<String, Object> getDataSchemaNodeAndIdentifiersByXpath(final String parentNodeXpath, + final SchemaContext schemaContext) { final String[] xpathNodeIdSequence = xpathToNodeIdSequence(parentNodeXpath); - return findDataSchemaNodeIdentifiersByXpathNodeIdSequence(xpathNodeIdSequence, schemaContext.getChildNodes(), + return findDataSchemaNodeAndIdentifiersByXpathNodeIdSequence(xpathNodeIdSequence, schemaContext.getChildNodes(), new ArrayList<>()); } @@ -181,7 +268,7 @@ public class YangUtils { return xpathNodeIdSequence; } - private static Collection<QName> findDataSchemaNodeIdentifiersByXpathNodeIdSequence( + private static Map<String, Object> findDataSchemaNodeAndIdentifiersByXpathNodeIdSequence( final String[] xpathNodeIdSequence, final Collection<? extends DataSchemaNode> dataSchemaNodes, final Collection<QName> dataSchemaNodeIdentifiers) { @@ -191,11 +278,15 @@ public class YangUtils { .findFirst().orElseThrow(() -> schemaNodeNotFoundException(currentXpathNodeId)); dataSchemaNodeIdentifiers.add(currentDataSchemaNode.getQName()); if (xpathNodeIdSequence.length <= 1) { - return dataSchemaNodeIdentifiers; + final Map<String, Object> dataSchemaNodeAndIdentifiers = + new HashMap<>(); + dataSchemaNodeAndIdentifiers.put("dataSchemaNode", currentDataSchemaNode); + dataSchemaNodeAndIdentifiers.put("dataSchemaNodeIdentifiers", dataSchemaNodeIdentifiers); + return dataSchemaNodeAndIdentifiers; } if (currentDataSchemaNode instanceof DataNodeContainer) { - return findDataSchemaNodeIdentifiersByXpathNodeIdSequence( - getNextLevelXpathNodeIdSequence(xpathNodeIdSequence), + return findDataSchemaNodeAndIdentifiersByXpathNodeIdSequence( + getNextLevelXpathNodeIdSequence(xpathNodeIdSequence), ((DataNodeContainer) currentDataSchemaNode).getChildNodes(), dataSchemaNodeIdentifiers); } @@ -212,4 +303,19 @@ public class YangUtils { return new DataValidationException("Invalid xpath.", String.format("No schema node was found for xpath identifier '%s'.", schemaNodeIdentifier)); } -} + + private static NormalizedNode getFirstChildXmlRoot(final NormalizedNode parent) { + final String rootNodeType = parent.getIdentifier().getNodeType().getLocalName(); + final Collection<DataContainerChild> children = (Collection<DataContainerChild>) parent.body(); + final Iterator<DataContainerChild> iterator = children.iterator(); + NormalizedNode child = null; + while (iterator.hasNext()) { + child = iterator.next(); + if (!child.getIdentifier().getNodeType().getLocalName().equals(rootNodeType) + && !(child instanceof LeafNode)) { + return child; + } + } + return getFirstChildXmlRoot(child); + } +}
\ 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 b78ab8a451..c81a50ea74 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 @@ -4,7 +4,7 @@ * Modifications Copyright (C) 2021 Pantheon.tech * Modifications Copyright (C) 2021-2022 Bell Canada. * Modifications Copyright (C) 2022 TechMahindra Ltd. - * ================================================================================ + * Modifications Copyright (C) 2022 Deutsche Telekom AG * 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 @@ -33,6 +33,7 @@ import org.onap.cps.spi.exceptions.DataValidationException import org.onap.cps.spi.model.Anchor import org.onap.cps.spi.model.DataNode import org.onap.cps.spi.model.DataNodeBuilder +import org.onap.cps.utils.ContentType import org.onap.cps.yang.YangTextSchemaSourceSet import org.onap.cps.yang.YangTextSchemaSourceSetBuilder import spock.lang.Specification @@ -61,7 +62,7 @@ class CpsDataServiceImplSpec extends Specification { def anchor = Anchor.builder().name(anchorName).schemaSetName(schemaSetName).build() def observedTimestamp = OffsetDateTime.now() - def 'Saving json data.'() { + def 'Saving multicontainer json data.'() { given: 'schema set for given anchor and dataspace references test-tree model' setupSchemaSetMocks('multipleDataTree.yang') when: 'save data method is invoked with test-tree json data' @@ -81,6 +82,39 @@ class CpsDataServiceImplSpec extends Specification { } + def 'Saving #scenario data.'() { + given: 'schema set for given anchor and dataspace references test-tree model' + setupSchemaSetMocks('test-tree.yang') + when: 'save data method is invoked with test-tree #scenario data' + def data = TestUtils.getResourceFileContent(dataFile) + objectUnderTest.saveData(dataspaceName, anchorName, data, observedTimestamp, contentType) + then: 'the persistence service method is invoked with correct parameters' + 1 * mockCpsDataPersistenceService.storeDataNodes(dataspaceName, anchorName, + { dataNode -> dataNode.xpath[0] == '/test-tree' }) + and: 'the CpsValidator is called on the dataspaceName and AnchorName' + 1 * mockCpsValidator.validateNameCharacters(dataspaceName, anchorName) + and: 'data updated event is sent to notification service' + 1 * mockNotificationService.processDataUpdatedEvent(dataspaceName, anchorName, '/', Operation.CREATE, observedTimestamp) + where: 'given parameters' + scenario | dataFile | contentType + 'json' | 'test-tree.json' | ContentType.JSON + 'xml' | 'test-tree.xml' | ContentType.XML + } + + def 'Saving #scenarioDesired data with invalid data.'() { + given: 'schema set for given anchor and dataspace references test-tree model' + setupSchemaSetMocks('test-tree.yang') + when: 'save data method is invoked with test-tree json data' + objectUnderTest.saveData(dataspaceName, anchorName, invalidData, observedTimestamp, contentType) + then: 'a data validation exception is thrown' + thrown(DataValidationException) + where: 'given parameters' + scenarioDesired | invalidData | contentType + 'json' | '{invalid json' | ContentType.XML + 'xml' | '<invalid xml' | ContentType.JSON + } + + def 'Saving child data fragment under existing node.'() { given: 'schema set for given anchor and dataspace references test-tree model' setupSchemaSetMocks('test-tree.yang') diff --git a/cps-service/src/test/groovy/org/onap/cps/utils/XmlFileUtilsSpec.groovy b/cps-service/src/test/groovy/org/onap/cps/utils/XmlFileUtilsSpec.groovy new file mode 100644 index 0000000000..b044e2e727 --- /dev/null +++ b/cps-service/src/test/groovy/org/onap/cps/utils/XmlFileUtilsSpec.groovy @@ -0,0 +1,61 @@ +/* + * ============LICENSE_START======================================================= + * Copyright (C) 2022 Deutsche Telekom AG + * ================================================================================ + * 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 org.onap.cps.TestUtils +import org.onap.cps.yang.YangTextSchemaSourceSetBuilder +import spock.lang.Specification + +class XmlFileUtilsSpec extends Specification { + def 'Parse a valid xml content #scenario'(){ + given: 'YANG model schema context' + def yangResourceNameToContent = TestUtils.getYangResourcesAsMap('bookstore.yang') + def schemaContext = YangTextSchemaSourceSetBuilder.of(yangResourceNameToContent).getSchemaContext() + when: 'the XML data is parsed' + def parsedXmlContent = XmlFileUtils.prepareXmlContent(xmlData, schemaContext) + then: 'the result XML is wrapped by root node defined in YANG schema' + assert parsedXmlContent == expectedOutput + where: + scenario | xmlData || expectedOutput + 'without root data node' | '<?xml version="1.0" encoding="UTF-8"?><class> </class>' || '<?xml version="1.0" encoding="UTF-8"?><stores xmlns="urn:ietf:params:xml:ns:netconf:base:1.0"><class> </class></stores>' + 'with root data node' | '<?xml version="1.0" encoding="UTF-8"?><stores><class> </class></stores>' || '<?xml version="1.0" encoding="UTF-8"?><stores><class> </class></stores>' + 'no xml header' | '<stores><class> </class></stores>' || '<stores><class> </class></stores>' + } + + def 'Parse a xml content with XPath container #scenario'() { + given: 'YANG model schema context' + def yangResourceNameToContent = TestUtils.getYangResourcesAsMap('test-tree.yang') + def schemaContext = YangTextSchemaSourceSetBuilder.of(yangResourceNameToContent).getSchemaContext() + and: 'Parent schema node by xPath' + def parentSchemaNode = YangUtils.getDataSchemaNodeAndIdentifiersByXpath(xPath, schemaContext) + .get("dataSchemaNode") + when: 'the XML data is parsed' + def parsedXmlContent = XmlFileUtils.prepareXmlContent(xmlData, parentSchemaNode, xPath) + then: 'the result XML is wrapped by xPath defined parent root node' + assert parsedXmlContent == expectedOutput + where: + scenario | xmlData | xPath || expectedOutput + 'XML element test tree' | '<?xml version="1.0" encoding="UTF-8"?><test-tree xmlns="org:onap:cps:test:test-tree"><branch><name>Left</name><nest><name>Small</name><birds>Sparrow</birds></nest></branch></test-tree>' | '/test-tree' || '<?xml version="1.0" encoding="UTF-8"?><test-tree xmlns="org:onap:cps:test:test-tree"><branch><name>Left</name><nest><name>Small</name><birds>Sparrow</birds></nest></branch></test-tree>' + 'without root data node' | '<?xml version="1.0" encoding="UTF-8"?><nest xmlns="org:onap:cps:test:test-tree"><name>Small</name><birds>Sparrow</birds></nest>' | '/test-tree/branch[@name=\'Branch\']' || '<?xml version="1.0" encoding="UTF-8"?><branch xmlns="org:onap:cps:test:test-tree"><name>Branch</name><nest><name>Small</name><birds>Sparrow</birds></nest></branch>' + + + } + +} diff --git a/cps-service/src/test/groovy/org/onap/cps/utils/YangUtilsSpec.groovy b/cps-service/src/test/groovy/org/onap/cps/utils/YangUtilsSpec.groovy index 990b7186f7..bf6e134a65 100644 --- a/cps-service/src/test/groovy/org/onap/cps/utils/YangUtilsSpec.groovy +++ b/cps-service/src/test/groovy/org/onap/cps/utils/YangUtilsSpec.groovy @@ -3,6 +3,7 @@ * Copyright (C) 2020-2022 Nordix Foundation * Modifications Copyright (C) 2021 Pantheon.tech * Modifications Copyright (C) 2022 TechMahindra Ltd. + * Modifications Copyright (C) 2022 Deutsche Telekom AG * ================================================================================ * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -30,7 +31,7 @@ import org.opendaylight.yangtools.yang.data.api.schema.NormalizedNode import spock.lang.Specification class YangUtilsSpec extends Specification { - def 'Parsing a valid Json String.'() { + def 'Parsing a valid multicontainer Json String.'() { given: 'a yang model (file)' def jsonData = org.onap.cps.TestUtils.getResourceFileContent('multiple-object-data.json') and: 'a model for that data' @@ -48,36 +49,62 @@ class YangUtilsSpec extends Specification { 1 | 'last-container' } + def 'Parsing a valid #scenario String.'() { + given: 'a yang model (file)' + def fileData = org.onap.cps.TestUtils.getResourceFileContent(contentFile) + and: 'a model for that data' + def yangResourceNameToContent = TestUtils.getYangResourcesAsMap('bookstore.yang') + def schemaContext = YangTextSchemaSourceSetBuilder.of(yangResourceNameToContent).getSchemaContext() + when: 'the data is parsed' + NormalizedNode result = YangUtils.parseData(contentType, fileData, schemaContext) + then: 'the result is a normalized node of the correct type' + if (revision) { + result.identifier.nodeType == QName.create(namespace, revision, localName) + } else { + result.identifier.nodeType == QName.create(namespace, localName) + } + where: + scenario | contentFile | contentType | namespace | revision | localName + 'JSON' | 'bookstore.json' | ContentType.JSON | 'org:onap:ccsdk:sample' | '2020-09-15' | 'bookstore' + 'XML' | 'bookstore.xml' | ContentType.XML | 'urn:ietf:params:xml:ns:netconf:base:1.0' | '' | 'bookstore' + } + def 'Parsing invalid data: #description.'() { given: 'a yang model (file)' def yangResourceNameToContent = TestUtils.getYangResourcesAsMap('bookstore.yang') def schemaContext = YangTextSchemaSourceSetBuilder.of(yangResourceNameToContent).getSchemaContext() when: 'invalid data is parsed' - YangUtils.parseJsonData(invalidJson, schemaContext) + YangUtils.parseData(contentType, invalidData, schemaContext) then: 'an exception is thrown' thrown(DataValidationException) - where: 'the following invalid json is provided' - invalidJson | description - '{incomplete json' | 'incomplete json' - '{"test:bookstore": {"address": "Parnell st." }}' | 'json with un-modelled data' - '{" }' | 'json with syntax exception' + where: 'the following invalid data is provided' + invalidData | contentType | description + '{incomplete json' | ContentType.JSON | 'incomplete json' + '{"test:bookstore": {"address": "Parnell st." }}' | ContentType.JSON | 'json with un-modelled data' + '{" }' | ContentType.JSON | 'json with syntax exception' + '<data>' | ContentType.XML | 'incomplete xml' + '<data><bookstore><bookstore-anything>blabla</bookstore-anything></bookstore</data>' | ContentType.XML | 'xml with invalid model' + '' | ContentType.XML | 'empty xml' } - def 'Parsing json data fragment by xpath for #scenario.'() { + def 'Parsing data fragment by xpath for #scenario.'() { given: 'schema context' def yangResourcesMap = TestUtils.getYangResourcesAsMap('test-tree.yang') def schemaContext = YangTextSchemaSourceSetBuilder.of(yangResourcesMap).getSchemaContext() when: 'json string is parsed' - def result = YangUtils.parseJsonData(jsonData, schemaContext, parentNodeXpath) + def result = YangUtils.parseData(contentType, nodeData, schemaContext, parentNodeXpath) then: 'a ContainerNode holding collection of normalized nodes is returned' result.body().getAt(0) instanceof NormalizedNode == true then: 'result represents a node of expected type' result.body().getAt(0).getIdentifier().nodeType == QName.create('org:onap:cps:test:test-tree', '2020-02-02', nodeName) where: - scenario | jsonData | parentNodeXpath || nodeName - 'list element as container' | '{ "branch": { "name": "B", "nest": { "name": "N", "birds": ["bird"] } } }' | '/test-tree' || 'branch' - 'list element within list' | '{ "branch": [{ "name": "B", "nest": { "name": "N", "birds": ["bird"] } }] }' | '/test-tree' || 'branch' - 'container element' | '{ "nest": { "name": "N", "birds": ["bird"] } }' | '/test-tree/branch[@name=\'Branch\']' || 'nest' + scenario | contentType | nodeData | parentNodeXpath || nodeName + 'JSON list element as container' | ContentType.JSON | '{ "branch": { "name": "B", "nest": { "name": "N", "birds": ["bird"] } } }' | '/test-tree' || 'branch' + 'JSON list element within list' | ContentType.JSON | '{ "branch": [{ "name": "B", "nest": { "name": "N", "birds": ["bird"] } }] }' | '/test-tree' || 'branch' + 'JSON container element' | ContentType.JSON | '{ "nest": { "name": "N", "birds": ["bird"] } }' | '/test-tree/branch[@name=\'Branch\']' || 'nest' + 'XML element test tree' | ContentType.XML | '<?xml version=\'1.0\' encoding=\'UTF-8\'?><branch xmlns="org:onap:cps:test:test-tree"><name>Left</name><nest><name>Small</name><birds>Sparrow</birds></nest></branch>' | '/test-tree' || 'branch' + 'XML element branch xpath' | ContentType.XML | '<?xml version=\'1.0\' encoding=\'UTF-8\'?><branch xmlns="org:onap:cps:test:test-tree"><name>Left</name><nest><name>Small</name><birds>Sparrow</birds><birds>Robin</birds></nest></branch>' | '/test-tree' || 'branch' + 'XML container element' | ContentType.XML | '<?xml version=\'1.0\' encoding=\'UTF-8\'?><nest xmlns="org:onap:cps:test:test-tree"><name>Small</name><birds>Sparrow</birds></nest>' | '/test-tree/branch[@name=\'Branch\']' || 'nest' } def 'Parsing json data fragment by xpath error scenario: #scenario.'() { @@ -135,5 +162,4 @@ class YangUtilsSpec extends Specification { 'xpath contains list attribute' | '/test-tree/branch[@name=\'Branch\']' || ['test-tree','branch'] 'xpath contains list attributes with /' | '/test-tree/branch[@name=\'/Branch\']/categories[@id=\'/broken\']' || ['test-tree','branch','categories'] } - } diff --git a/cps-service/src/test/resources/bookstore.xml b/cps-service/src/test/resources/bookstore.xml new file mode 100644 index 0000000000..dd45e16896 --- /dev/null +++ b/cps-service/src/test/resources/bookstore.xml @@ -0,0 +1,19 @@ +<?xml version='1.0' encoding='UTF-8'?> +<stores xmlns="urn:ietf:params:xml:ns:netconf:base:1.0"> +<bookstore xmlns="org:onap:ccsdk:sample"> + <bookstore-name>Chapters</bookstore-name> + <categories> + <code>1</code> + <name>SciFi</name> + <books> + <title>2001: A Space Odyssey</title> + <lang>en</lang> + <authors> + Iain M. Banks + </authors> + <pub_year>1994</pub_year> + <price>895</price> + </books> + </categories> +</bookstore> +</stores>
\ No newline at end of file diff --git a/cps-service/src/test/resources/bookstore_xpath.xml b/cps-service/src/test/resources/bookstore_xpath.xml new file mode 100644 index 0000000000..e206901d6d --- /dev/null +++ b/cps-service/src/test/resources/bookstore_xpath.xml @@ -0,0 +1,17 @@ +<?xml version='1.0' encoding='UTF-8'?> +<bookstore xmlns="org:onap:ccsdk:sample"> + <bookstore-name>Chapters</bookstore-name> + <categories> + <code>1</code> + <name>SciFi</name> + <books> + <title>2001: A Space Odyssey</title> + <lang>en</lang> + <authors> + Iain M. Banks + </authors> + <pub_year>1994</pub_year> + <price>895</price> + </books> + </categories> +</bookstore>
\ No newline at end of file diff --git a/cps-service/src/test/resources/test-tree.xml b/cps-service/src/test/resources/test-tree.xml new file mode 100644 index 0000000000..3daa814cf6 --- /dev/null +++ b/cps-service/src/test/resources/test-tree.xml @@ -0,0 +1,27 @@ +<?xml version='1.0' encoding='UTF-8'?> +<data xmlns="urn:ietf:params:xml:ns:netconf:base:1.0"> + <test-tree xmlns="org:onap:cps:test:test-tree"> + <branch> + <name>Left</name> + <nest> + <name>Small</name> + <birds>Sparrow</birds> + <birds>Robin</birds> + <birds>Finch</birds> + </nest> + </branch> + <branch> + <name>Right</name> + <nest> + <name>Big</name> + <birds>Owl</birds> + <birds>Raven</birds> + <birds>Crow</birds> + </nest> + </branch> + <fruit> + <name>Apple</name> + <color>Green</color> + </fruit> + </test-tree> +</data> |