From 673c6d94830a1677e685cab82a76747a0808d347 Mon Sep 17 00:00:00 2001 From: aditya puthuparambil Date: Tue, 24 Aug 2021 17:44:34 +0100 Subject: Add optional observed timestamp in the cps data api - Added optional query parameter in cps data endpoints - Updated service layer and notification to use observedTimestamp Note: - NCMP REST endpoints are not updated as a part of this patch - NCMP does not sent observed timestamp when using cps data services Issue-ID: CPS-477 Signed-off-by: puthuparambil.aditya Change-Id: I1f92da3da7b3a13c45405fdf44e5fef861991d9a Signed-off-by: Renu Kumari --- .../api/impl/NetworkCmProxyDataServiceImpl.java | 20 +- .../impl/NetworkCmProxyDataServiceImplSpec.groovy | 28 ++- .../cps/rest/controller/DataRestController.java | 58 ++++-- .../rest/exceptions/CpsRestExceptionHandler.java | 6 + cps-rest/src/main/resources/static/components.yml | 8 + cps-rest/src/main/resources/static/cpsData.yml | 6 + .../rest/controller/DataRestControllerSpec.groovy | 231 +++++++++++++++------ .../exceptions/CpsRestExceptionHandlerSpec.groovy | 21 +- .../java/org/onap/cps/utils/DateTimeUtility.java | 40 ++++ .../main/java/org/onap/cps/api/CpsDataService.java | 66 +++--- .../org/onap/cps/api/impl/CpsDataServiceImpl.java | 43 ++-- .../notification/CpsDataUpdatedEventFactory.java | 25 ++- .../onap/cps/notification/NotificationService.java | 7 +- .../cps/api/impl/CpsDataServiceImplSpec.groovy | 75 +++---- .../onap/cps/api/impl/E2ENetworkSliceSpec.groovy | 6 +- .../CpsDataUpdateEventFactorySpec.groovy | 37 +++- .../notification/NotificationServiceSpec.groovy | 17 +- .../java/org/onap/cps/utils/DateTimeUtility.java | 40 ++++ 18 files changed, 508 insertions(+), 226 deletions(-) create mode 100644 cps-rest/src/test/java/org/onap/cps/utils/DateTimeUtility.java create mode 100644 cps-service/src/test/java/org/onap/cps/utils/DateTimeUtility.java diff --git a/cps-ncmp-service/src/main/java/org/onap/cps/ncmp/api/impl/NetworkCmProxyDataServiceImpl.java b/cps-ncmp-service/src/main/java/org/onap/cps/ncmp/api/impl/NetworkCmProxyDataServiceImpl.java index 235030a84c..b5a591401a 100755 --- a/cps-ncmp-service/src/main/java/org/onap/cps/ncmp/api/impl/NetworkCmProxyDataServiceImpl.java +++ b/cps-ncmp-service/src/main/java/org/onap/cps/ncmp/api/impl/NetworkCmProxyDataServiceImpl.java @@ -3,6 +3,7 @@ * Copyright (C) 2021 highstreet technologies GmbH * Modifications Copyright (C) 2021 Nordix Foundation * Modifications Copyright (C) 2021 Pantheon.tech + * Modifications Copyright (C) 2021 Bell Canada * ================================================================================ * 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 @@ package org.onap.cps.ncmp.api.impl; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.OffsetDateTime; import java.util.Collection; import java.util.Collections; import java.util.LinkedHashMap; @@ -62,6 +64,8 @@ public class NetworkCmProxyDataServiceImpl implements NetworkCmProxyDataService private static final String NCMP_DMI_REGISTRY_ANCHOR = "ncmp-dmi-registry"; + private static final OffsetDateTime NO_TIMESTAMP = null; + private CpsDataService cpsDataService; private ObjectMapper objectMapper; @@ -105,25 +109,25 @@ public class NetworkCmProxyDataServiceImpl implements NetworkCmProxyDataService @Override public void createDataNode(final String cmHandle, final String parentNodeXpath, final String jsonData) { if (!StringUtils.hasText(parentNodeXpath) || "/".equals(parentNodeXpath)) { - cpsDataService.saveData(getDataspaceName(), cmHandle, jsonData); + cpsDataService.saveData(getDataspaceName(), cmHandle, jsonData, NO_TIMESTAMP); } else { - cpsDataService.saveData(getDataspaceName(), cmHandle, parentNodeXpath, jsonData); + cpsDataService.saveData(getDataspaceName(), cmHandle, parentNodeXpath, jsonData, NO_TIMESTAMP); } } @Override public void addListNodeElements(final String cmHandle, final String parentNodeXpath, final String jsonData) { - cpsDataService.saveListNodeData(getDataspaceName(), cmHandle, parentNodeXpath, jsonData); + cpsDataService.saveListNodeData(getDataspaceName(), cmHandle, parentNodeXpath, jsonData, NO_TIMESTAMP); } @Override public void updateNodeLeaves(final String cmHandle, final String parentNodeXpath, final String jsonData) { - cpsDataService.updateNodeLeaves(getDataspaceName(), cmHandle, parentNodeXpath, jsonData); + cpsDataService.updateNodeLeaves(getDataspaceName(), cmHandle, parentNodeXpath, jsonData, NO_TIMESTAMP); } @Override public void replaceNodeTree(final String cmHandle, final String parentNodeXpath, final String jsonData) { - cpsDataService.replaceNodeTree(getDataspaceName(), cmHandle, parentNodeXpath, jsonData); + cpsDataService.replaceNodeTree(getDataspaceName(), cmHandle, parentNodeXpath, jsonData, NO_TIMESTAMP); } @Override @@ -150,7 +154,7 @@ public class NetworkCmProxyDataServiceImpl implements NetworkCmProxyDataService persistenceCmHandlesList.setCmHandles(createdPersistenceCmHandles); final String cmHandleJsonData = objectMapper.writeValueAsString(persistenceCmHandlesList); cpsDataService.saveListNodeData(NCMP_DATASPACE_NAME, NCMP_DMI_REGISTRY_ANCHOR, "/dmi-registry", - cmHandleJsonData); + cmHandleJsonData, NO_TIMESTAMP); } catch (final JsonProcessingException e) { log.error("Parsing error occurred while converting Object to JSON for Dmi Registry."); throw new DataValidationException( @@ -170,7 +174,7 @@ public class NetworkCmProxyDataServiceImpl implements NetworkCmProxyDataService persistenceCmHandlesList.setCmHandles(updatedPersistenceCmHandles); final String cmHandlesJsonData = objectMapper.writeValueAsString(persistenceCmHandlesList); cpsDataService.updateNodeLeavesAndExistingDescendantLeaves(NCMP_DATASPACE_NAME, NCMP_DMI_REGISTRY_ANCHOR, - "/dmi-registry", cmHandlesJsonData); + "/dmi-registry", cmHandlesJsonData, NO_TIMESTAMP); } catch (final JsonProcessingException e) { log.error("Parsing error occurred while converting Object to JSON Dmi Registry."); throw new DataValidationException( @@ -183,7 +187,7 @@ public class NetworkCmProxyDataServiceImpl implements NetworkCmProxyDataService for (final String cmHandle: dmiPluginRegistration.getRemovedCmHandles()) { try { cpsDataService.deleteListNodeData(NCMP_DATASPACE_NAME, NCMP_DMI_REGISTRY_ANCHOR, - "/dmi-registry/cm-handles[@id='" + cmHandle + "']"); + "/dmi-registry/cm-handles[@id='" + cmHandle + "']", NO_TIMESTAMP); } catch (final DataNodeNotFoundException e) { log.warn("Datanode {} not deleted message {}", cmHandle, e.getMessage()); } diff --git a/cps-ncmp-service/src/test/groovy/org/onap/cps/ncmp/api/impl/NetworkCmProxyDataServiceImplSpec.groovy b/cps-ncmp-service/src/test/groovy/org/onap/cps/ncmp/api/impl/NetworkCmProxyDataServiceImplSpec.groovy index 0760167181..45fa0af454 100644 --- a/cps-ncmp-service/src/test/groovy/org/onap/cps/ncmp/api/impl/NetworkCmProxyDataServiceImplSpec.groovy +++ b/cps-ncmp-service/src/test/groovy/org/onap/cps/ncmp/api/impl/NetworkCmProxyDataServiceImplSpec.groovy @@ -2,6 +2,7 @@ * ============LICENSE_START======================================================= * Copyright (C) 2021 Nordix Foundation * Modifications Copyright (C) 2021 Pantheon.tech + * Modifications Copyright (C) 2021 Bell Canada * ================================================================================ * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -49,6 +50,7 @@ class NetworkCmProxyDataServiceImplSpec extends Specification { def objectUnderTest = new NetworkCmProxyDataServiceImpl(mockDmiOperations, mockCpsDataService, mockCpsQueryService, new ObjectMapper()) def cmHandle = 'some handle' + def noTimestamp = null def expectedDataspaceName = 'NFP-Operational' def 'Query data nodes by cps path with #fetchDescendantsOption.'() { @@ -67,7 +69,7 @@ class NetworkCmProxyDataServiceImplSpec extends Specification { when: 'createDataNode is invoked' objectUnderTest.createDataNode(cmHandle, xpath, jsonData) then: 'the CPS service method is invoked once with the expected parameters' - 1 * mockCpsDataService.saveData(expectedDataspaceName, cmHandle, jsonData) + 1 * mockCpsDataService.saveData(expectedDataspaceName, cmHandle, jsonData, noTimestamp) where: 'following parameters were used' scenario | xpath 'no xpath' | '' @@ -80,7 +82,7 @@ class NetworkCmProxyDataServiceImplSpec extends Specification { when: 'createDataNode is invoked' objectUnderTest.createDataNode(cmHandle, xpath, jsonData) then: 'the CPS service method is invoked once with the expected parameters' - 1 * mockCpsDataService.saveData(expectedDataspaceName, cmHandle, xpath, jsonData) + 1 * mockCpsDataService.saveData(expectedDataspaceName, cmHandle, xpath, jsonData, noTimestamp) } def 'Add list-node elements.'() { given: 'a cm handle and parent node xpath' @@ -89,7 +91,7 @@ class NetworkCmProxyDataServiceImplSpec extends Specification { when: 'addListNodeElements is invoked' objectUnderTest.addListNodeElements(cmHandle, xpath, jsonData) then: 'the CPS service method is invoked once with the expected parameters' - 1 * mockCpsDataService.saveListNodeData(expectedDataspaceName, cmHandle, xpath, jsonData) + 1 * mockCpsDataService.saveListNodeData(expectedDataspaceName, cmHandle, xpath, jsonData, noTimestamp) } def 'Update data node leaves.'() { given: 'a cm Handle and a cps path' @@ -98,7 +100,7 @@ class NetworkCmProxyDataServiceImplSpec extends Specification { when: 'updateNodeLeaves is invoked' objectUnderTest.updateNodeLeaves(cmHandle, xpath, jsonData) then: 'the persistence service is called once with the correct parameters' - 1 * mockCpsDataService.updateNodeLeaves(expectedDataspaceName, cmHandle, xpath, jsonData) + 1 * mockCpsDataService.updateNodeLeaves(expectedDataspaceName, cmHandle, xpath, jsonData, noTimestamp) } def 'Replace data node tree.'() { given: 'a cm Handle and a cps path' @@ -107,7 +109,7 @@ class NetworkCmProxyDataServiceImplSpec extends Specification { when: 'replaceNodeTree is invoked' objectUnderTest.replaceNodeTree(cmHandle, xpath, jsonData) then: 'the persistence service is called once with the correct parameters' - 1 * mockCpsDataService.replaceNodeTree(expectedDataspaceName, cmHandle, xpath, jsonData) + 1 * mockCpsDataService.replaceNodeTree(expectedDataspaceName, cmHandle, xpath, jsonData, noTimestamp) } def 'Register or re-register a DMI Plugin with #scenario cm handles.'() { @@ -123,11 +125,15 @@ class NetworkCmProxyDataServiceImplSpec extends Specification { when: 'registration is updated' objectUnderTest.updateDmiPluginRegistration(dmiPluginRegistration) then: 'the CPS save list node data is invoked with the expected parameters' - expectedCallsToSaveNode * mockCpsDataService.saveListNodeData('NCMP-Admin', 'ncmp-dmi-registry', '/dmi-registry', expectedJsonData) + expectedCallsToSaveNode * mockCpsDataService.saveListNodeData('NCMP-Admin', 'ncmp-dmi-registry', + '/dmi-registry', expectedJsonData, noTimestamp) and: 'update Node and Child Data Nodes is invoked with correct parameters' - expectedCallsToUpdateNode * mockCpsDataService.updateNodeLeavesAndExistingDescendantLeaves('NCMP-Admin', 'ncmp-dmi-registry', '/dmi-registry', expectedJsonData) + expectedCallsToUpdateNode * mockCpsDataService.updateNodeLeavesAndExistingDescendantLeaves('NCMP-Admin', + 'ncmp-dmi-registry', '/dmi-registry', expectedJsonData, noTimestamp) and : 'delete list data node is invoked with the correct parameters' - expectedCallsToDeleteListDataNode * mockCpsDataService.deleteListNodeData('NCMP-Admin', 'ncmp-dmi-registry', "/dmi-registry/cm-handles[@id='cmHandle001']") + expectedCallsToDeleteListDataNode * mockCpsDataService.deleteListNodeData('NCMP-Admin', + 'ncmp-dmi-registry', "/dmi-registry/cm-handles[@id='cmHandle001']", noTimestamp) + where: scenario | createdCmHandles | updatedCmHandles | removedCmHandles || expectedCallsToSaveNode | expectedCallsToUpdateNode | expectedCallsToDeleteListDataNode 'create' | [persistenceCmHandle ] | [] | [] || 1 | 0 | 0 @@ -148,7 +154,8 @@ class NetworkCmProxyDataServiceImplSpec extends Specification { when: 'registration is updated' objectUnderTest.updateDmiPluginRegistration(dmiPluginRegistration) then: 'the CPS save list node data is invoked with the expected parameters' - 1 * mockCpsDataService.saveListNodeData('NCMP-Admin', 'ncmp-dmi-registry', '/dmi-registry', expectedJsonData) + 1 * mockCpsDataService.saveListNodeData('NCMP-Admin', 'ncmp-dmi-registry', + '/dmi-registry', expectedJsonData, noTimestamp) } def 'Get resource data for pass-through operational from dmi.'() { @@ -176,7 +183,8 @@ class NetworkCmProxyDataServiceImplSpec extends Specification { 'testFieldQuery', 5, 'testAcceptParam', - '{"operation":"read","cmHandleProperties":{"testName":"testValue"}}') >> new ResponseEntity<>('result-json', HttpStatus.OK) + '{"operation":"read","cmHandleProperties":{"testName":"testValue"}}') >> + new ResponseEntity<>('result-json', HttpStatus.OK) and: 'dmi returns ok response' response == 'result-json' } 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 5c79472a4c..0e2050e5cc 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 @@ -1,6 +1,6 @@ /* * ============LICENSE_START======================================================= - * Copyright (C) 2020 Bell Canada. + * Copyright (C) 2020-2021 Bell Canada. * Modifications Copyright (C) 2021 Pantheon.tech * Modifications Copyright (C) 2021 Nordix Foundation * ================================================================================ @@ -9,6 +9,7 @@ * 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. @@ -21,8 +22,10 @@ package org.onap.cps.rest.controller; -import javax.validation.Valid; -import javax.validation.constraints.NotNull; +import java.time.OffsetDateTime; +import java.time.format.DateTimeFormatter; +import javax.validation.ValidationException; +import org.apache.commons.lang3.StringUtils; import org.onap.cps.api.CpsDataService; import org.onap.cps.rest.api.CpsDataApi; import org.onap.cps.spi.FetchDescendantsOption; @@ -38,25 +41,29 @@ import org.springframework.web.bind.annotation.RestController; public class DataRestController implements CpsDataApi { private static final String ROOT_XPATH = "/"; + private static final String ISO_TIMESTAMP_FORMAT = "yyyy-MM-dd'T'HH:mm:ss.SSSZ"; + private static final DateTimeFormatter ISO_TIMESTAMP_FORMATTER = DateTimeFormatter.ofPattern(ISO_TIMESTAMP_FORMAT); @Autowired private CpsDataService cpsDataService; @Override public ResponseEntity createNode(final String dataspaceName, final String anchorName, - final String jsonData, final String parentNodeXpath) { + final String jsonData, final String parentNodeXpath, final String observedTimestamp) { if (isRootXpath(parentNodeXpath)) { - cpsDataService.saveData(dataspaceName, anchorName, jsonData); + cpsDataService.saveData(dataspaceName, anchorName, jsonData, toOffsetDateTime(observedTimestamp)); } else { - cpsDataService.saveData(dataspaceName, anchorName, parentNodeXpath, jsonData); + cpsDataService.saveData(dataspaceName, anchorName, parentNodeXpath, jsonData, + toOffsetDateTime(observedTimestamp)); } return new ResponseEntity<>(HttpStatus.CREATED); } @Override public ResponseEntity addListNodeElements(final String parentNodeXpath, - final String dataspaceName, final String anchorName, final String jsonData) { - cpsDataService.saveListNodeData(dataspaceName, anchorName, parentNodeXpath, jsonData); + final String dataspaceName, final String anchorName, final String jsonData, final String observedTimestamp) { + cpsDataService.saveListNodeData(dataspaceName, anchorName, parentNodeXpath, jsonData, + toOffsetDateTime(observedTimestamp)); return new ResponseEntity<>(HttpStatus.CREATED); } @@ -77,33 +84,48 @@ public class DataRestController implements CpsDataApi { @Override public ResponseEntity updateNodeLeaves(final String dataspaceName, - final String anchorName, final String jsonData, final String parentNodeXpath) { - cpsDataService.updateNodeLeaves(dataspaceName, anchorName, parentNodeXpath, jsonData); + final String anchorName, final String jsonData, final String parentNodeXpath, final String observedTimestamp) { + cpsDataService.updateNodeLeaves(dataspaceName, anchorName, parentNodeXpath, jsonData, + toOffsetDateTime(observedTimestamp)); return new ResponseEntity<>(HttpStatus.OK); } @Override - public ResponseEntity replaceNode(final String dataspaceName, - final String anchorName, @Valid final String jsonData, @Valid final String parentNodeXpath) { - cpsDataService.replaceNodeTree(dataspaceName, anchorName, parentNodeXpath, jsonData); + public ResponseEntity replaceNode(final String dataspaceName, final String anchorName, + final String jsonData, final String parentNodeXpath, final String observedTimestamp) { + cpsDataService + .replaceNodeTree(dataspaceName, anchorName, parentNodeXpath, jsonData, toOffsetDateTime(observedTimestamp)); return new ResponseEntity<>(HttpStatus.OK); } @Override - public ResponseEntity replaceListNodeElements(@NotNull @Valid final String parentNodeXpath, - final String dataspaceName, final String anchorName, @Valid final String jsonData) { - cpsDataService.replaceListNodeData(dataspaceName, anchorName, parentNodeXpath, jsonData); + public ResponseEntity replaceListNodeElements(final String parentNodeXpath, + final String dataspaceName, final String anchorName, final String jsonData, + final String observedTimestamp) { + cpsDataService.replaceListNodeData(dataspaceName, anchorName, parentNodeXpath, jsonData, + toOffsetDateTime(observedTimestamp)); return new ResponseEntity<>(HttpStatus.OK); } @Override public ResponseEntity deleteListNodeElements(final String dataspaceName, final String anchorName, - final String listNodeXpath) { - cpsDataService.deleteListNodeData(dataspaceName, anchorName, listNodeXpath); + final String listNodeXpath, final String observedTimestamp) { + cpsDataService + .deleteListNodeData(dataspaceName, anchorName, listNodeXpath, toOffsetDateTime(observedTimestamp)); return new ResponseEntity<>(HttpStatus.NO_CONTENT); } private static boolean isRootXpath(final String xpath) { return ROOT_XPATH.equals(xpath); } + + private OffsetDateTime toOffsetDateTime(final String datetTimestamp) { + try { + return StringUtils.isEmpty(datetTimestamp) + ? null : OffsetDateTime.parse(datetTimestamp, ISO_TIMESTAMP_FORMATTER); + } catch (final Exception exception) { + throw new ValidationException( + String.format("observed-timestamp must be in '%s' format", ISO_TIMESTAMP_FORMAT)); + } + } } diff --git a/cps-rest/src/main/java/org/onap/cps/rest/exceptions/CpsRestExceptionHandler.java b/cps-rest/src/main/java/org/onap/cps/rest/exceptions/CpsRestExceptionHandler.java index 143ad8b886..d790e0802d 100755 --- a/cps-rest/src/main/java/org/onap/cps/rest/exceptions/CpsRestExceptionHandler.java +++ b/cps-rest/src/main/java/org/onap/cps/rest/exceptions/CpsRestExceptionHandler.java @@ -22,6 +22,7 @@ package org.onap.cps.rest.exceptions; import javax.servlet.http.HttpServletRequest; +import javax.validation.ValidationException; import lombok.extern.slf4j.Slf4j; import org.onap.cps.rest.controller.AdminRestController; import org.onap.cps.rest.controller.DataRestController; @@ -67,6 +68,11 @@ public class CpsRestExceptionHandler { return buildErrorResponse(HttpStatus.BAD_REQUEST, exception); } + @ExceptionHandler({ValidationException.class}) + public static ResponseEntity handleBadRequestExceptions(final ValidationException validationException) { + return buildErrorResponse(HttpStatus.BAD_REQUEST, validationException); + } + @ExceptionHandler({NotFoundInDataspaceException.class, DataNodeNotFoundException.class}) public static ResponseEntity handleNotFoundExceptions(final CpsException exception, final HttpServletRequest request) { diff --git a/cps-rest/src/main/resources/static/components.yml b/cps-rest/src/main/resources/static/components.yml index 51a49a6e9f..75a6f99fc9 100644 --- a/cps-rest/src/main/resources/static/components.yml +++ b/cps-rest/src/main/resources/static/components.yml @@ -158,6 +158,14 @@ components: schema: type: boolean default: false + observedTimestampInQuery: + name: observed-timestamp + in: query + description: observed-timestamp + required: false + schema: + type: string + example: '2021-03-21T00:10:34.030-0100' responses: NotFound: diff --git a/cps-rest/src/main/resources/static/cpsData.yml b/cps-rest/src/main/resources/static/cpsData.yml index 9c4f3334e1..75d954473d 100644 --- a/cps-rest/src/main/resources/static/cpsData.yml +++ b/cps-rest/src/main/resources/static/cpsData.yml @@ -55,6 +55,7 @@ listNodeByDataspaceAndAnchor: - $ref: 'components.yml#/components/parameters/dataspaceNameInPath' - $ref: 'components.yml#/components/parameters/anchorNameInPath' - $ref: 'components.yml#/components/parameters/requiredXpathInQuery' + - $ref: 'components.yml#/components/parameters/observedTimestampInQuery' requestBody: required: true content: @@ -81,6 +82,7 @@ listNodeByDataspaceAndAnchor: - $ref: 'components.yml#/components/parameters/dataspaceNameInPath' - $ref: 'components.yml#/components/parameters/anchorNameInPath' - $ref: 'components.yml#/components/parameters/requiredXpathInQuery' + - $ref: 'components.yml#/components/parameters/observedTimestampInQuery' requestBody: required: true content: @@ -107,6 +109,7 @@ listNodeByDataspaceAndAnchor: - $ref: 'components.yml#/components/parameters/dataspaceNameInPath' - $ref: 'components.yml#/components/parameters/anchorNameInPath' - $ref: 'components.yml#/components/parameters/requiredXpathInQuery' + - $ref: 'components.yml#/components/parameters/observedTimestampInQuery' responses: '204': $ref: 'components.yml#/components/responses/NoContent' @@ -128,6 +131,7 @@ nodesByDataspaceAndAnchor: - $ref: 'components.yml#/components/parameters/dataspaceNameInPath' - $ref: 'components.yml#/components/parameters/anchorNameInPath' - $ref: 'components.yml#/components/parameters/xpathInQuery' + - $ref: 'components.yml#/components/parameters/observedTimestampInQuery' requestBody: required: true content: @@ -154,6 +158,7 @@ nodesByDataspaceAndAnchor: - $ref: 'components.yml#/components/parameters/dataspaceNameInPath' - $ref: 'components.yml#/components/parameters/anchorNameInPath' - $ref: 'components.yml#/components/parameters/xpathInQuery' + - $ref: 'components.yml#/components/parameters/observedTimestampInQuery' requestBody: required: true content: @@ -180,6 +185,7 @@ nodesByDataspaceAndAnchor: - $ref: 'components.yml#/components/parameters/dataspaceNameInPath' - $ref: 'components.yml#/components/parameters/anchorNameInPath' - $ref: 'components.yml#/components/parameters/xpathInQuery' + - $ref: 'components.yml#/components/parameters/observedTimestampInQuery' requestBody: required: true content: 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 d3d42e3065..1d51ec4aca 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 @@ -30,13 +30,10 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put -import org.modelmapper.ModelMapper -import org.onap.cps.api.CpsAdminService import org.onap.cps.api.CpsDataService -import org.onap.cps.api.CpsModuleService -import org.onap.cps.api.CpsQueryService import org.onap.cps.spi.model.DataNode import org.onap.cps.spi.model.DataNodeBuilder +import org.onap.cps.utils.DateTimeUtility import org.spockframework.spring.SpringBean import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Value @@ -62,14 +59,15 @@ class DataRestControllerSpec extends Specification { def dataNodeBaseEndpoint def dataspaceName = 'my_dataspace' def anchorName = 'my_anchor' + def noTimestamp = null @Shared static DataNode dataNodeWithLeavesNoChildren = new DataNodeBuilder().withXpath('/xpath') - .withLeaves([leaf: 'value', leafList: ['leaveListElement1', 'leaveListElement2']]).build() + .withLeaves([leaf: 'value', leafList: ['leaveListElement1', 'leaveListElement2']]).build() @Shared static DataNode dataNodeWithChild = new DataNodeBuilder().withXpath('/parent') - .withChildDataNodes([new DataNodeBuilder().withXpath("/parent/child").build()]).build() + .withChildDataNodes([new DataNodeBuilder().withXpath("/parent/child").build()]).build() def setup() { dataNodeBaseEndpoint = "$basePath/v1/dataspaces/$dataspaceName" @@ -81,22 +79,46 @@ class DataRestControllerSpec extends Specification { def json = 'some json (this is not validated)' when: 'post is invoked with datanode endpoint and json' def response = - mvc.perform( - post(endpoint) - .contentType(MediaType.APPLICATION_JSON) - .param('xpath', parentNodeXpath) - .content(json) - ).andReturn().response + mvc.perform( + post(endpoint) + .contentType(MediaType.APPLICATION_JSON) + .param('xpath', parentNodeXpath) + .content(json) + ).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, json) + 1 * mockCpsDataService.saveData(dataspaceName, anchorName, json, noTimestamp) where: 'following xpath parameters are are used' scenario | parentNodeXpath 'no xpath parameter' | '' 'xpath parameter point root' | '/' } + def 'Create a node with observed-timestamp'() { + given: 'some json to create a data node' + def endpoint = "$dataNodeBaseEndpoint/anchors/$anchorName/nodes" + def json = 'some json (this is not validated)' + when: 'post is invoked with datanode endpoint and json' + def response = + mvc.perform( + post(endpoint) + .contentType(MediaType.APPLICATION_JSON) + .param('xpath', '') + .param('observed-timestamp', observedTimestamp) + .content(json) + ).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, json, + { it == DateTimeUtility.toOffsetDateTime(observedTimestamp) }) + 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 + } + def 'Create a child node'() { given: 'some json to create a data node' def endpoint = "$dataNodeBaseEndpoint/anchors/$anchorName/nodes" @@ -104,34 +126,47 @@ class DataRestControllerSpec extends Specification { 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) + .param('xpath', parentNodeXpath) + .content(json) + if (observedTimestamp != null) + postRequestBuilder.param('observed-timestamp', observedTimestamp) def response = - mvc.perform( - post(endpoint) - .contentType(MediaType.APPLICATION_JSON) - .param('xpath', parentNodeXpath) - .content(json) - ).andReturn().response + mvc.perform(postRequestBuilder).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, parentNodeXpath, json) + 1 * mockCpsDataService.saveData(dataspaceName, anchorName, parentNodeXpath, json, + DateTimeUtility.toOffsetDateTime(observedTimestamp)) + where: + scenario | observedTimestamp + 'with observed-timestamp' | '2021-03-03T23:59:59.999-0400' + 'without observed-timestamp' | null } - def 'Create list node child elements.'() { + def 'Create list node child elements #scenario.'() { given: 'parent node xpath and json data inputs' def parentNodeXpath = 'parent node xpath' def jsonData = 'json data' when: 'post is invoked list-node endpoint' - def response = mvc.perform( - post("$dataNodeBaseEndpoint/anchors/$anchorName/list-node") - .contentType(MediaType.APPLICATION_JSON) - .param('xpath', parentNodeXpath) - .content(jsonData) - ).andReturn().response + def postRequestBuilder = post("$dataNodeBaseEndpoint/anchors/$anchorName/list-node") + .contentType(MediaType.APPLICATION_JSON) + .param('xpath', parentNodeXpath) + .content(jsonData) + if (observedTimestamp != null) + postRequestBuilder.param('observed-timestamp', observedTimestamp) + def response = mvc.perform(postRequestBuilder).andReturn().response then: 'a created response is returned' - response.status == HttpStatus.CREATED.value() + response.status == expectedHttpStatus.value() then: 'the java API was called with the correct parameters' - 1 * mockCpsDataService.saveListNodeData(dataspaceName, anchorName, parentNodeXpath, jsonData) + expectedApiCount * mockCpsDataService.saveListNodeData(dataspaceName, anchorName, parentNodeXpath, jsonData, + { it == DateTimeUtility.toOffsetDateTime(observedTimestamp) }) + where: + scenario | observedTimestamp || expectedApiCount | expectedHttpStatus + 'with observed-timestamp' | '2021-03-03T23:59:59.999-0400' || 1 | HttpStatus.CREATED + 'without observed-timestamp' | null || 1 | HttpStatus.CREATED + 'with invalid observed-timestamp' | 'invalid' || 0 | HttpStatus.BAD_REQUEST } def 'Get data node with leaves'() { @@ -141,8 +176,8 @@ class DataRestControllerSpec extends Specification { mockCpsDataService.getDataNode(dataspaceName, anchorName, xpath, OMIT_DESCENDANTS) >> dataNodeWithLeavesNoChildren when: 'get request is performed through REST API' def response = - mvc.perform(get(endpoint).param('xpath', xpath)) - .andReturn().response + mvc.perform(get(endpoint).param('xpath', xpath)) + .andReturn().response then: 'a success response is returned' response.status == HttpStatus.OK.value() and: 'response contains expected leaf and value' @@ -158,11 +193,11 @@ class DataRestControllerSpec extends Specification { mockCpsDataService.getDataNode(dataspaceName, anchorName, xpath, expectedCpsDataServiceOption) >> dataNode when: 'get request is performed through REST API' def response = - mvc.perform( - get(endpoint) - .param('xpath', xpath) - .param('include-descendants', includeDescendantsOption)) - .andReturn().response + mvc.perform( + get(endpoint) + .param('xpath', xpath) + .param('include-descendants', includeDescendantsOption)) + .andReturn().response then: 'a success response is returned' response.status == HttpStatus.OK.value() and: 'the response contains child is #expectChildInResponse' @@ -180,14 +215,14 @@ class DataRestControllerSpec extends Specification { def endpoint = "$dataNodeBaseEndpoint/anchors/$anchorName/nodes" when: 'patch request is performed' def response = - mvc.perform( - patch(endpoint) - .contentType(MediaType.APPLICATION_JSON) - .content(jsonData) - .param('xpath', inputXpath) - ).andReturn().response + mvc.perform( + patch(endpoint) + .contentType(MediaType.APPLICATION_JSON) + .content(jsonData) + .param('xpath', inputXpath) + ).andReturn().response then: 'the service method is invoked with expected parameters' - 1 * mockCpsDataService.updateNodeLeaves(dataspaceName, anchorName, xpathServiceParameter, jsonData) + 1 * mockCpsDataService.updateNodeLeaves(dataspaceName, anchorName, xpathServiceParameter, jsonData, null) and: 'response status indicates success' response.status == HttpStatus.OK.value() where: @@ -197,20 +232,44 @@ class DataRestControllerSpec extends Specification { 'some xpath by parent' | '/some/xpath' || '/some/xpath' } + def 'Update data node leaves with observedTimestamp'() { + given: 'json data' + def jsonData = 'json data' + def endpoint = "$dataNodeBaseEndpoint/anchors/$anchorName/nodes" + when: 'patch request is performed' + def response = + mvc.perform( + patch(endpoint) + .contentType(MediaType.APPLICATION_JSON) + .content(jsonData) + .param('xpath', '/') + .param('observed-timestamp', observedTimestamp) + ).andReturn().response + then: 'the service method is invoked with expected parameters' + expectedApiCount * mockCpsDataService.updateNodeLeaves(dataspaceName, anchorName, '/', jsonData, + { it == DateTimeUtility.toOffsetDateTime(observedTimestamp) }) + and: 'response status indicates success' + response.status == expectedHttpStatus.value() + where: + scenario | observedTimestamp || expectedApiCount | expectedHttpStatus + 'with observed-timestamp' | '2021-03-03T23:59:59.999-0400' || 1 | HttpStatus.OK + 'with invalid observed-timestamp' | 'invalid' || 0 | HttpStatus.BAD_REQUEST + } + def 'Replace data node tree: #scenario.'() { given: 'json data' def jsonData = 'json data' def endpoint = "$dataNodeBaseEndpoint/anchors/$anchorName/nodes" when: 'put request is performed' def response = - mvc.perform( - put(endpoint) - .contentType(MediaType.APPLICATION_JSON) - .content(jsonData) - .param('xpath', inputXpath)) - .andReturn().response + mvc.perform( + put(endpoint) + .contentType(MediaType.APPLICATION_JSON) + .content(jsonData) + .param('xpath', inputXpath)) + .andReturn().response then: 'the service method is invoked with expected parameters' - 1 * mockCpsDataService.replaceNodeTree(dataspaceName, anchorName, xpathServiceParameter, jsonData) + 1 * mockCpsDataService.replaceNodeTree(dataspaceName, anchorName, xpathServiceParameter, jsonData, noTimestamp) and: 'response status indicates success' response.status == HttpStatus.OK.value() where: @@ -220,34 +279,72 @@ class DataRestControllerSpec extends Specification { 'some xpath by parent' | '/some/xpath' || '/some/xpath' } + def 'Replace data node tree with observedTimestamp.'() { + given: 'json data' + def jsonData = 'json data' + def endpoint = "$dataNodeBaseEndpoint/anchors/$anchorName/nodes" + when: 'put request is performed' + def response = + mvc.perform( + put(endpoint) + .contentType(MediaType.APPLICATION_JSON) + .content(jsonData) + .param('xpath', '') + .param('observed-timestamp', observedTimestamp)) + .andReturn().response + then: 'the service method is invoked with expected parameters' + expectedApiCount * mockCpsDataService.replaceNodeTree(dataspaceName, anchorName, '/', jsonData, + { it == DateTimeUtility.toOffsetDateTime(observedTimestamp) }) + and: 'response status indicates success' + response.status == expectedHttpStatus.value() + where: + scenario | observedTimestamp || expectedApiCount | expectedHttpStatus + 'with observed-timestamp' | '2021-03-03T23:59:59.999-0400' || 1 | HttpStatus.OK + 'with invalid observed-timestamp' | 'invalid' || 0 | HttpStatus.BAD_REQUEST + } + def 'Replace list node child elements.'() { given: 'parent node xpath and json data inputs' def parentNodeXpath = 'parent node xpath' def jsonData = 'json data' when: 'patch is invoked list-node endpoint' - def response = mvc.perform( - patch("$dataNodeBaseEndpoint/anchors/$anchorName/list-node") - .contentType(MediaType.APPLICATION_JSON) - .param('xpath', parentNodeXpath) - .content(jsonData) - ).andReturn().response + def patchRequestBuilder = patch("$dataNodeBaseEndpoint/anchors/$anchorName/list-node") + .contentType(MediaType.APPLICATION_JSON) + .param('xpath', parentNodeXpath) + .content(jsonData) + if (observedTimestamp != null) + patchRequestBuilder.param('observed-timestamp', observedTimestamp) + def response = mvc.perform(patchRequestBuilder).andReturn().response then: 'a success response is returned' - response.status == HttpStatus.OK.value() - then: 'the java API was called with the correct parameters' - 1 * mockCpsDataService.replaceListNodeData(dataspaceName, anchorName, parentNodeXpath, jsonData) + response.status == expectedHttpStatus.value() + and: 'the java API was called with the correct parameters' + expectedApiCount * mockCpsDataService.replaceListNodeData(dataspaceName, anchorName, parentNodeXpath, jsonData, + { it == DateTimeUtility.toOffsetDateTime(observedTimestamp) }) + where: + scenario | observedTimestamp || expectedApiCount | expectedHttpStatus + 'with observed-timestamp' | '2021-03-03T23:59:59.999-0400' || 1 | HttpStatus.OK + 'without observed-timestamp' | null || 1 | HttpStatus.OK + 'with invalid observed-timestamp' | 'invalid' || 0 | HttpStatus.BAD_REQUEST } - def 'Delete list node child elements.'() { + def 'Delete list node child elements. #scenario'() { given: 'list node xpath' def listNodeXpath = 'list node xpath' when: 'delete is invoked list-node endpoint' - def response = mvc.perform( - delete("$dataNodeBaseEndpoint/anchors/$anchorName/list-node") - .param('xpath', listNodeXpath) - ).andReturn().response + def deleteRequestBuilder = delete("$dataNodeBaseEndpoint/anchors/$anchorName/list-node") + .param('xpath', listNodeXpath) + if (observedTimestamp != null) + deleteRequestBuilder.param('observed-timestamp', observedTimestamp) + def response = mvc.perform(deleteRequestBuilder).andReturn().response then: 'a success response is returned' - response.status == HttpStatus.NO_CONTENT.value() - then: 'the java API was called with the correct parameters' - 1 * mockCpsDataService.deleteListNodeData(dataspaceName, anchorName, listNodeXpath) + response.status == expectedHttpStatus.value() + and: 'the java API was called with the correct parameters' + expectedApiCount * mockCpsDataService.deleteListNodeData(dataspaceName, anchorName, listNodeXpath, + { it == DateTimeUtility.toOffsetDateTime(observedTimestamp) }) + where: + scenario | observedTimestamp || expectedApiCount | expectedHttpStatus + 'with observed-timestamp' | '2021-03-03T23:59:59.999-0400' || 1 | HttpStatus.NO_CONTENT + 'without observed-timestamp' | null || 1 | HttpStatus.NO_CONTENT + 'with invalid observed-timestamp' | 'invalid' || 0 | HttpStatus.BAD_REQUEST } } 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 f44518d4b8..079a59c667 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 @@ -111,7 +111,7 @@ class CpsRestExceptionHandlerSpec extends Specification { def response = performTestRequest() then: 'an HTTP Not Found response is returned with correct message and details' assertTestResponse(response, NOT_FOUND, 'Object not found', - 'Description does not exist in dataspace MyDataSpace.') + 'Description does not exist in dataspace MyDataSpace.') } def 'Request with an object already defined exception returns HTTP Status Conflict.'() { @@ -120,8 +120,8 @@ class CpsRestExceptionHandlerSpec extends Specification { def response = performTestRequest() then: 'a HTTP conflict response is returned with correct message an details' assertTestResponse(response, CONFLICT, - "Already defined exception", - "Anchor with name ${existingObjectName} already exists for ${dataspaceName}.") + "Already defined exception", + "Anchor with name ${existingObjectName} already exists for ${dataspaceName}.") } def 'Get request with a #exceptionThrown.class.simpleName returns HTTP Status Bad Request'() { @@ -152,15 +152,16 @@ class CpsRestExceptionHandlerSpec extends Specification { * NB. This method tests the expected behavior for POST request only; * testing of PUT and PATCH requests omitted due to same NOT 'GET' condition is being used. */ + 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('json data') + post("$basePath/v1/dataspaces/dataspace-name/anchors/anchor-name/nodes") + .contentType(MediaType.APPLICATION_JSON) + .param('xpath', 'parent node xpath') + .content('json data') ).andReturn().response then: 'response code indicates bad input parameters' response.status == BAD_REQUEST.value() @@ -179,8 +180,8 @@ class CpsRestExceptionHandlerSpec extends Specification { def performTestRequest() { return mvc.perform( - get("$basePath/v1/dataspaces/dataspace-name/anchors")) - .andReturn().response + get("$basePath/v1/dataspaces/dataspace-name/anchors")) + .andReturn().response } static void assertTestResponse(response, expectedStatus, expectedErrorMessage, expectedErrorDetails) { diff --git a/cps-rest/src/test/java/org/onap/cps/utils/DateTimeUtility.java b/cps-rest/src/test/java/org/onap/cps/utils/DateTimeUtility.java new file mode 100644 index 0000000000..f8d709647c --- /dev/null +++ b/cps-rest/src/test/java/org/onap/cps/utils/DateTimeUtility.java @@ -0,0 +1,40 @@ +/* + * ============LICENSE_START======================================================= + * Copyright (c) 2021 Bell Canada. + * ================================================================================ + * 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.time.OffsetDateTime; +import java.time.format.DateTimeFormatter; +import org.springframework.util.StringUtils; + +public interface DateTimeUtility { + + String ISO_TIMESTAMP_PATTERN = "yyyy-MM-dd'T'HH:mm:ss.SSSZ"; + DateTimeFormatter ISO_TIMESTAMP_FORMATTER = DateTimeFormatter.ofPattern(ISO_TIMESTAMP_PATTERN); + + static OffsetDateTime toOffsetDateTime(String datetTimestampAsString) { + return ! StringUtils.hasLength(datetTimestampAsString) + ? null : OffsetDateTime.parse(datetTimestampAsString, ISO_TIMESTAMP_FORMATTER); + } + + static String toString(OffsetDateTime offsetDateTime) { + return offsetDateTime != null ? ISO_TIMESTAMP_FORMATTER.format(offsetDateTime) : null; + } +} 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 2583e9905b..31a7517340 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 @@ -2,6 +2,7 @@ * ============LICENSE_START======================================================= * Copyright (C) 2020 Nordix Foundation * Modifications Copyright (C) 2021 Pantheon.tech + * Modifications Copyright (C) 2021 Bell Canada * ================================================================================ * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -21,6 +22,7 @@ package org.onap.cps.api; +import java.time.OffsetDateTime; import org.checkerframework.checker.nullness.qual.NonNull; import org.onap.cps.spi.FetchDescendantsOption; import org.onap.cps.spi.model.DataNode; @@ -36,8 +38,10 @@ public interface CpsDataService { * @param dataspaceName dataspace name * @param anchorName anchor name * @param jsonData json data + * @param observedTimestamp observedTimestamp */ - void saveData(@NonNull String dataspaceName, @NonNull String anchorName, @NonNull String jsonData); + void saveData(@NonNull String dataspaceName, @NonNull String anchorName, @NonNull String jsonData, + OffsetDateTime observedTimestamp); /** * Persists child data fragment under existing data node for the given anchor and dataspace. @@ -46,21 +50,23 @@ public interface CpsDataService { * @param anchorName anchor name * @param parentNodeXpath parent node xpath * @param jsonData json data + * @param observedTimestamp observedTimestamp */ void saveData(@NonNull String dataspaceName, @NonNull String anchorName, @NonNull String parentNodeXpath, - @NonNull String jsonData); + @NonNull String jsonData, OffsetDateTime observedTimestamp); /** - * Persists child data fragment representing list-node (with one or more elements) under existing data node - * for the given anchor and dataspace. + * Persists child data fragment representing list-node (with one or more elements) under existing data node for the + * given anchor and dataspace. * * @param dataspaceName dataspace name * @param anchorName anchor name * @param parentNodeXpath parent node xpath * @param jsonData json data representing list element + * @param observedTimestamp observedTimestamp */ void saveListNodeData(@NonNull String dataspaceName, @NonNull String anchorName, @NonNull String parentNodeXpath, - @NonNull String jsonData); + @NonNull String jsonData, OffsetDateTime observedTimestamp); /** * Retrieves datanode by XPath for given dataspace and anchor. @@ -82,9 +88,10 @@ public interface CpsDataService { * @param anchorName anchor name * @param parentNodeXpath xpath to parent node * @param jsonData json data + * @param observedTimestamp observedTimestamp */ void updateNodeLeaves(@NonNull String dataspaceName, @NonNull String anchorName, @NonNull String parentNodeXpath, - @NonNull String jsonData); + @NonNull String jsonData, OffsetDateTime observedTimestamp); /** * Replaces existing data node content including descendants. @@ -93,42 +100,47 @@ public interface CpsDataService { * @param anchorName anchor name * @param parentNodeXpath xpath to parent node * @param jsonData json data + * @param observedTimestamp observedTimestamp */ void replaceNodeTree(@NonNull String dataspaceName, @NonNull String anchorName, @NonNull String parentNodeXpath, - @NonNull String jsonData); + @NonNull String jsonData, OffsetDateTime observedTimestamp); /** - * Replaces (if exists) child data fragment representing list-node (with one or more elements) - * under existing data node for the given anchor and dataspace. + * Replaces (if exists) child data fragment representing list-node (with one or more elements) under existing data + * node for the given anchor and dataspace. * - * @param dataspaceName dataspace name - * @param anchorName anchor name - * @param parentNodeXpath parent node xpath - * @param jsonData json data representing list element + * @param dataspaceName dataspace name + * @param anchorName anchor name + * @param parentNodeXpath parent node xpath + * @param jsonData json data representing list element + * @param observedTimestamp observedTimestamp */ void replaceListNodeData(@NonNull String dataspaceName, @NonNull String anchorName, @NonNull String parentNodeXpath, - @NonNull String jsonData); + @NonNull String jsonData, OffsetDateTime observedTimestamp); /** - * Deletes (if exists) child data fragment representing list-node (with one or more elements) - * under existing data node for the given anchor and dataspace. + * Deletes (if exists) child data fragment representing list-node (with one or more elements) under existing data + * node for the given anchor and dataspace. * - * @param dataspaceName dataspace name - * @param anchorName anchor name - * @param listNodeXpath list node xpath + * @param dataspaceName dataspace name + * @param anchorName anchor name + * @param listNodeXpath list node xpath + * @param observedTimestamp observedTimestamp */ - void deleteListNodeData(@NonNull String dataspaceName, @NonNull String anchorName, @NonNull String listNodeXpath); + void deleteListNodeData(@NonNull String dataspaceName, @NonNull String anchorName, @NonNull String listNodeXpath, + OffsetDateTime observedTimestamp); /** - * Updates leaves of DataNode for given dataspace and anchor using xpath, - * along with the leaves of each Child Data Node which already exists. - * This method will throw an exception if data node update or any descendant update does not exist. + * Updates leaves of DataNode for given dataspace and anchor using xpath, along with the leaves of each Child Data + * Node which already exists. This method will throw an exception if data node update or any descendant update does + * not exist. * - * @param dataspaceName dataspace name - * @param anchorName anchor name - * @param parentNodeXpath xpath + * @param dataspaceName dataspace name + * @param anchorName anchor name + * @param parentNodeXpath xpath * @param dataNodeUpdatesAsJson json data representing data node updates + * @param observedTimestamp observedTimestamp */ void updateNodeLeavesAndExistingDescendantLeaves(String dataspaceName, String anchorName, String parentNodeXpath, - String dataNodeUpdatesAsJson); + String dataNodeUpdatesAsJson, OffsetDateTime observedTimestamp); } 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 8989dc80ef..7b3567ed3c 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 @@ -22,6 +22,7 @@ package org.onap.cps.api.impl; +import java.time.OffsetDateTime; import java.util.Collection; import lombok.extern.slf4j.Slf4j; import org.onap.cps.api.CpsAdminService; @@ -61,27 +62,28 @@ public class CpsDataServiceImpl implements CpsDataService { private NotificationService notificationService; @Override - public void saveData(final String dataspaceName, final String anchorName, final String jsonData) { + public void saveData(final String dataspaceName, final String anchorName, final String jsonData, + final OffsetDateTime observedTimestamp) { final var dataNode = buildDataNodeFromJson(dataspaceName, anchorName, ROOT_NODE_XPATH, jsonData); cpsDataPersistenceService.storeDataNode(dataspaceName, anchorName, dataNode); - processDataUpdatedEventAsync(dataspaceName, anchorName); + processDataUpdatedEventAsync(dataspaceName, anchorName, observedTimestamp); } @Override public void saveData(final String dataspaceName, final String anchorName, final String parentNodeXpath, - final String jsonData) { + final String jsonData, final OffsetDateTime observedTimestamp) { final var dataNode = buildDataNodeFromJson(dataspaceName, anchorName, parentNodeXpath, jsonData); cpsDataPersistenceService.addChildDataNode(dataspaceName, anchorName, parentNodeXpath, dataNode); - processDataUpdatedEventAsync(dataspaceName, anchorName); + processDataUpdatedEventAsync(dataspaceName, anchorName, observedTimestamp); } @Override public void saveListNodeData(final String dataspaceName, final String anchorName, - final String parentNodeXpath, final String jsonData) { + final String parentNodeXpath, final String jsonData, final OffsetDateTime observedTimestamp) { final Collection dataNodesCollection = buildDataNodeCollectionFromJson(dataspaceName, anchorName, parentNodeXpath, jsonData); cpsDataPersistenceService.addListDataNodes(dataspaceName, anchorName, parentNodeXpath, dataNodesCollection); - processDataUpdatedEventAsync(dataspaceName, anchorName); + processDataUpdatedEventAsync(dataspaceName, anchorName, observedTimestamp); } @Override @@ -92,46 +94,48 @@ public class CpsDataServiceImpl implements CpsDataService { @Override public void updateNodeLeaves(final String dataspaceName, final String anchorName, final String parentNodeXpath, - final String jsonData) { + final String jsonData, final OffsetDateTime observedTimestamp) { final var dataNode = buildDataNodeFromJson(dataspaceName, anchorName, parentNodeXpath, jsonData); cpsDataPersistenceService .updateDataLeaves(dataspaceName, anchorName, dataNode.getXpath(), dataNode.getLeaves()); - processDataUpdatedEventAsync(dataspaceName, anchorName); + processDataUpdatedEventAsync(dataspaceName, anchorName, observedTimestamp); } @Override public void updateNodeLeavesAndExistingDescendantLeaves(final String dataspaceName, final String anchorName, - final String parentNodeXpath, - final String dataNodeUpdatesAsJson) { + final String parentNodeXpath, + final String dataNodeUpdatesAsJson, + final OffsetDateTime observedTimestamp) { final Collection dataNodeUpdates = buildDataNodeCollectionFromJson(dataspaceName, anchorName, parentNodeXpath, dataNodeUpdatesAsJson); for (final DataNode dataNodeUpdate : dataNodeUpdates) { processDataNodeUpdate(dataspaceName, anchorName, dataNodeUpdate); } - notificationService.processDataUpdatedEvent(dataspaceName, anchorName); + processDataUpdatedEventAsync(dataspaceName, anchorName, observedTimestamp); } @Override public void replaceNodeTree(final String dataspaceName, final String anchorName, final String parentNodeXpath, - final String jsonData) { + final String jsonData, final OffsetDateTime observedTimestamp) { final var dataNode = buildDataNodeFromJson(dataspaceName, anchorName, parentNodeXpath, jsonData); cpsDataPersistenceService.replaceDataNodeTree(dataspaceName, anchorName, dataNode); - processDataUpdatedEventAsync(dataspaceName, anchorName); + processDataUpdatedEventAsync(dataspaceName, anchorName, observedTimestamp); } @Override public void replaceListNodeData(final String dataspaceName, final String anchorName, final String parentNodeXpath, - final String jsonData) { + final String jsonData, final OffsetDateTime observedTimestamp) { final Collection dataNodes = buildDataNodeCollectionFromJson(dataspaceName, anchorName, parentNodeXpath, jsonData); cpsDataPersistenceService.replaceListDataNodes(dataspaceName, anchorName, parentNodeXpath, dataNodes); - processDataUpdatedEventAsync(dataspaceName, anchorName); + processDataUpdatedEventAsync(dataspaceName, anchorName, observedTimestamp); } @Override - public void deleteListNodeData(final String dataspaceName, final String anchorName, final String listNodeXpath) { + public void deleteListNodeData(final String dataspaceName, final String anchorName, final String listNodeXpath, + final OffsetDateTime observedTimestamp) { cpsDataPersistenceService.deleteListDataNodes(dataspaceName, anchorName, listNodeXpath); - processDataUpdatedEventAsync(dataspaceName, anchorName); + processDataUpdatedEventAsync(dataspaceName, anchorName, observedTimestamp); } @@ -171,9 +175,10 @@ public class CpsDataServiceImpl implements CpsDataService { } - private void processDataUpdatedEventAsync(final String dataspaceName, final String anchorName) { + private void processDataUpdatedEventAsync(final String dataspaceName, final String anchorName, + final OffsetDateTime observedTimestamp) { try { - notificationService.processDataUpdatedEvent(dataspaceName, anchorName); + notificationService.processDataUpdatedEvent(dataspaceName, anchorName, observedTimestamp); } catch (final Exception exception) { log.error("Failed to send message to notification service", exception); } diff --git a/cps-service/src/main/java/org/onap/cps/notification/CpsDataUpdatedEventFactory.java b/cps-service/src/main/java/org/onap/cps/notification/CpsDataUpdatedEventFactory.java index e0c8fe7055..85e5abab0d 100644 --- a/cps-service/src/main/java/org/onap/cps/notification/CpsDataUpdatedEventFactory.java +++ b/cps-service/src/main/java/org/onap/cps/notification/CpsDataUpdatedEventFactory.java @@ -42,7 +42,7 @@ public class CpsDataUpdatedEventFactory { private static final URI EVENT_SCHEMA; private static final URI EVENT_SOURCE; private static final String EVENT_TYPE = "org.onap.cps.data-updated-event"; - private static final DateTimeFormatter dateTimeFormatter = + private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSZ"); static { @@ -64,22 +64,25 @@ public class CpsDataUpdatedEventFactory { } /** - * Generates CPS Data Updated event. + * Generates CPS Data Updated event. If observedTimestamp is not provided, then current timestamp is used. * - * @param dataspaceName dataspaceName - * @param anchorName anchorName + * @param dataspaceName dataspaceName + * @param anchorName anchorName + * @param observedTimestamp observedTimestamp * @return CpsDataUpdatedEvent */ - public CpsDataUpdatedEvent createCpsDataUpdatedEvent(final String dataspaceName, final String anchorName) { + public CpsDataUpdatedEvent createCpsDataUpdatedEvent(final String dataspaceName, final String anchorName, + final OffsetDateTime observedTimestamp) { final var dataNode = cpsDataService .getDataNode(dataspaceName, anchorName, "/", FetchDescendantsOption.INCLUDE_ALL_DESCENDANTS); final var anchor = cpsAdminService.getAnchor(dataspaceName, anchorName); - return toCpsDataUpdatedEvent(anchor, dataNode); + return toCpsDataUpdatedEvent(anchor, dataNode, observedTimestamp); } - private CpsDataUpdatedEvent toCpsDataUpdatedEvent(final Anchor anchor, final DataNode dataNode) { + private CpsDataUpdatedEvent toCpsDataUpdatedEvent(final Anchor anchor, final DataNode dataNode, + final OffsetDateTime observedTimestamp) { final var cpsDataUpdatedEvent = new CpsDataUpdatedEvent(); - cpsDataUpdatedEvent.withContent(createContent(anchor, dataNode)); + cpsDataUpdatedEvent.withContent(createContent(anchor, dataNode, observedTimestamp)); cpsDataUpdatedEvent.withId(UUID.randomUUID().toString()); cpsDataUpdatedEvent.withSchema(EVENT_SCHEMA); cpsDataUpdatedEvent.withSource(EVENT_SOURCE); @@ -93,13 +96,15 @@ public class CpsDataUpdatedEventFactory { return data; } - private Content createContent(final Anchor anchor, final DataNode dataNode) { + private Content createContent(final Anchor anchor, final DataNode dataNode, + final OffsetDateTime observedTimestamp) { final var content = new Content(); content.withAnchorName(anchor.getName()); content.withDataspaceName(anchor.getDataspaceName()); content.withSchemaSetName(anchor.getSchemaSetName()); content.withData(createData(dataNode)); - content.withObservedTimestamp(dateTimeFormatter.format(OffsetDateTime.now())); + content.withObservedTimestamp( + DATE_TIME_FORMATTER.format(observedTimestamp == null ? OffsetDateTime.now() : observedTimestamp)); return content; } } diff --git a/cps-service/src/main/java/org/onap/cps/notification/NotificationService.java b/cps-service/src/main/java/org/onap/cps/notification/NotificationService.java index 4745739a4f..029efbe795 100644 --- a/cps-service/src/main/java/org/onap/cps/notification/NotificationService.java +++ b/cps-service/src/main/java/org/onap/cps/notification/NotificationService.java @@ -20,6 +20,7 @@ package org.onap.cps.notification; +import java.time.OffsetDateTime; import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -79,15 +80,17 @@ public class NotificationService { * * @param dataspaceName dataspace name * @param anchorName anchor name + * @param observedTimestamp observedTimestamp * @return future */ @Async("notificationExecutor") - public Future processDataUpdatedEvent(final String dataspaceName, final String anchorName) { + public Future processDataUpdatedEvent(final String dataspaceName, final String anchorName, + final OffsetDateTime observedTimestamp) { log.debug("process data updated event for dataspace '{}' & anchor '{}'", dataspaceName, anchorName); try { if (shouldSendNotification(dataspaceName)) { final var cpsDataUpdatedEvent = - cpsDataUpdatedEventFactory.createCpsDataUpdatedEvent(dataspaceName, anchorName); + cpsDataUpdatedEventFactory.createCpsDataUpdatedEvent(dataspaceName, anchorName, observedTimestamp); log.debug("data updated event to be published {}", cpsDataUpdatedEvent); notificationPublisher.sendNotification(cpsDataUpdatedEvent); } 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 97eac5aaa9..6a0a4649a6 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 @@ -22,6 +22,7 @@ package org.onap.cps.api.impl +import java.time.OffsetDateTime import org.onap.cps.TestUtils import org.onap.cps.api.CpsAdminService import org.onap.cps.api.CpsModuleService @@ -55,18 +56,19 @@ class CpsDataServiceImplSpec extends Specification { def dataspaceName = 'some dataspace' def anchorName = 'some anchor' def schemaSetName = 'some schema set' + def observedTimestamp = OffsetDateTime.now() def 'Saving json 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' def jsonData = TestUtils.getResourceFileContent('test-tree.json') - objectUnderTest.saveData(dataspaceName, anchorName, jsonData) + objectUnderTest.saveData(dataspaceName, anchorName, jsonData, observedTimestamp) then: 'the persistence service method is invoked with correct parameters' 1 * mockCpsDataPersistenceService.storeDataNode(dataspaceName, anchorName, - { dataNode -> dataNode.xpath == '/test-tree' }) + { dataNode -> dataNode.xpath == '/test-tree' }) and: 'data updated event is sent to notification service' - 1 * mockNotificationService.processDataUpdatedEvent(dataspaceName, anchorName) + 1 * mockNotificationService.processDataUpdatedEvent(dataspaceName, anchorName, observedTimestamp) } def 'Saving child data fragment under existing node.'() { @@ -74,12 +76,12 @@ class CpsDataServiceImplSpec extends Specification { setupSchemaSetMocks('test-tree.yang') when: 'save data method is invoked with test-tree json data' def jsonData = '{"branch": [{"name": "New"}]}' - objectUnderTest.saveData(dataspaceName, anchorName, '/test-tree', jsonData) + objectUnderTest.saveData(dataspaceName, anchorName, '/test-tree', jsonData, observedTimestamp) then: 'the persistence service method is invoked with correct parameters' 1 * mockCpsDataPersistenceService.addChildDataNode(dataspaceName, anchorName, '/test-tree', - { dataNode -> dataNode.xpath == '/test-tree/branch[@name=\'New\']' }) + { dataNode -> dataNode.xpath == '/test-tree/branch[@name=\'New\']' }) and: 'data updated event is sent to notification service' - 1 * mockNotificationService.processDataUpdatedEvent(dataspaceName, anchorName) + 1 * mockNotificationService.processDataUpdatedEvent(dataspaceName, anchorName, observedTimestamp) } def 'Saving list-node data fragment under existing node.'() { @@ -87,19 +89,19 @@ class CpsDataServiceImplSpec extends Specification { setupSchemaSetMocks('test-tree.yang') when: 'save data method is invoked with list-node json data' def jsonData = '{"branch": [{"name": "A"}, {"name": "B"}]}' - objectUnderTest.saveListNodeData(dataspaceName, anchorName, '/test-tree', jsonData) + objectUnderTest.saveListNodeData(dataspaceName, anchorName, '/test-tree', jsonData, observedTimestamp) then: 'the persistence service method is invoked with correct parameters' 1 * mockCpsDataPersistenceService.addListDataNodes(dataspaceName, anchorName, '/test-tree', - { dataNodeCollection -> - { - assert dataNodeCollection.size() == 2 - assert dataNodeCollection.collect { it.getXpath() } - .containsAll(['/test-tree/branch[@name=\'A\']', '/test-tree/branch[@name=\'B\']']) - } + { dataNodeCollection -> + { + assert dataNodeCollection.size() == 2 + assert dataNodeCollection.collect { it.getXpath() } + .containsAll(['/test-tree/branch[@name=\'A\']', '/test-tree/branch[@name=\'B\']']) } + } ) and: 'data updated event is sent to notification service' - 1 * mockNotificationService.processDataUpdatedEvent(dataspaceName, anchorName) + 1 * mockNotificationService.processDataUpdatedEvent(dataspaceName, anchorName, observedTimestamp) } def 'Saving empty list-node data fragment.'() { @@ -107,7 +109,7 @@ class CpsDataServiceImplSpec extends Specification { setupSchemaSetMocks('test-tree.yang') when: 'save data method is invoked with empty list-node data fragment' def jsonData = '{"branch": []}' - objectUnderTest.saveListNodeData(dataspaceName, anchorName, '/test-tree', jsonData) + objectUnderTest.saveListNodeData(dataspaceName, anchorName, '/test-tree', jsonData, observedTimestamp) then: 'invalid data exception is thrown' thrown(DataValidationException) } @@ -127,11 +129,11 @@ class CpsDataServiceImplSpec extends Specification { given: 'schema set for given anchor and dataspace references test-tree model' setupSchemaSetMocks('test-tree.yang') when: 'update data method is invoked with json data #jsonData and parent node xpath #parentNodeXpath' - objectUnderTest.updateNodeLeaves(dataspaceName, anchorName, parentNodeXpath, jsonData) + objectUnderTest.updateNodeLeaves(dataspaceName, anchorName, parentNodeXpath, jsonData, observedTimestamp) then: 'the persistence service method is invoked with correct parameters' 1 * mockCpsDataPersistenceService.updateDataLeaves(dataspaceName, anchorName, expectedNodeXpath, leaves) and: 'data updated event is sent to notification service' - 1 * mockNotificationService.processDataUpdatedEvent(dataspaceName, anchorName) + 1 * mockNotificationService.processDataUpdatedEvent(dataspaceName, anchorName, observedTimestamp) where: 'following parameters were used' scenario | parentNodeXpath | jsonData || expectedNodeXpath | leaves 'top level node' | '/' | '{"test-tree": {"branch": []}}' || '/test-tree' | Collections.emptyMap() @@ -142,7 +144,8 @@ class CpsDataServiceImplSpec extends Specification { given: 'schema set for given anchor and dataspace references bookstore model' setupSchemaSetMocks('bookstore.yang') when: 'update data method is invoked with json data #jsonData and parent node xpath' - objectUnderTest.updateNodeLeaves(dataspaceName, anchorName, '/bookstore/categories[@code=2]', jsonData) + objectUnderTest.updateNodeLeaves(dataspaceName, anchorName, '/bookstore/categories[@code=2]', + jsonData, observedTimestamp) then: 'the persistence service method is invoked with correct parameters' thrown(DataValidationException) where: 'following parameters were used' @@ -157,23 +160,25 @@ class CpsDataServiceImplSpec extends Specification { and: 'the expected json string' def jsonData = '{"cm-handles":[{"id":"cmHandle001", "additional-properties":[{"name":"P1"}]}]}' when: 'update data method is invoked with json data and parent node xpath' - objectUnderTest.updateNodeLeavesAndExistingDescendantLeaves(dataspaceName, anchorName, '/dmi-registry', jsonData) + objectUnderTest.updateNodeLeavesAndExistingDescendantLeaves(dataspaceName, anchorName, + '/dmi-registry', jsonData, observedTimestamp) then: 'the persistence service method is invoked with correct parameters' - 1 * mockCpsDataPersistenceService.updateDataLeaves(dataspaceName, anchorName, "/dmi-registry/cm-handles[@id='cmHandle001']", ['id': 'cmHandle001']) + 1 * mockCpsDataPersistenceService.updateDataLeaves(dataspaceName, anchorName, + "/dmi-registry/cm-handles[@id='cmHandle001']", ['id': 'cmHandle001']) and: 'the data updated event is sent to the notification service' - 1 * mockNotificationService.processDataUpdatedEvent(dataspaceName, anchorName) + 1 * mockNotificationService.processDataUpdatedEvent(dataspaceName, anchorName, observedTimestamp) } def 'Replace data node: #scenario.'() { given: 'schema set for given anchor and dataspace references test-tree model' setupSchemaSetMocks('test-tree.yang') when: 'replace data method is invoked with json data #jsonData and parent node xpath #parentNodeXpath' - objectUnderTest.replaceNodeTree(dataspaceName, anchorName, parentNodeXpath, jsonData) + objectUnderTest.replaceNodeTree(dataspaceName, anchorName, parentNodeXpath, jsonData, observedTimestamp) then: 'the persistence service method is invoked with correct parameters' 1 * mockCpsDataPersistenceService.replaceDataNodeTree(dataspaceName, anchorName, - { dataNode -> dataNode.xpath == expectedNodeXpath }) + { dataNode -> dataNode.xpath == expectedNodeXpath }) and: 'data updated event is sent to notification service' - 1 * mockNotificationService.processDataUpdatedEvent(dataspaceName, anchorName) + 1 * mockNotificationService.processDataUpdatedEvent(dataspaceName, anchorName, observedTimestamp) where: 'following parameters were used' scenario | parentNodeXpath | jsonData || expectedNodeXpath 'top level node' | '/' | '{"test-tree": {"branch": []}}' || '/test-tree' @@ -185,19 +190,19 @@ class CpsDataServiceImplSpec extends Specification { setupSchemaSetMocks('test-tree.yang') when: 'replace list data method is invoked with list-node json data' def jsonData = '{"branch": [{"name": "A"}, {"name": "B"}]}' - objectUnderTest.replaceListNodeData(dataspaceName, anchorName, '/test-tree', jsonData) + objectUnderTest.replaceListNodeData(dataspaceName, anchorName, '/test-tree', jsonData, observedTimestamp) then: 'the persistence service method is invoked with correct parameters' 1 * mockCpsDataPersistenceService.replaceListDataNodes(dataspaceName, anchorName, '/test-tree', - { dataNodeCollection -> - { - assert dataNodeCollection.size() == 2 - assert dataNodeCollection.collect { it.getXpath() } - .containsAll(['/test-tree/branch[@name=\'A\']', '/test-tree/branch[@name=\'B\']']) - } + { dataNodeCollection -> + { + assert dataNodeCollection.size() == 2 + assert dataNodeCollection.collect { it.getXpath() } + .containsAll(['/test-tree/branch[@name=\'A\']', '/test-tree/branch[@name=\'B\']']) } + } ) and: 'data updated event is sent to notification service' - 1 * mockNotificationService.processDataUpdatedEvent(dataspaceName, anchorName) + 1 * mockNotificationService.processDataUpdatedEvent(dataspaceName, anchorName, observedTimestamp) } def 'Replace with empty list-node data fragment.'() { @@ -205,7 +210,7 @@ class CpsDataServiceImplSpec extends Specification { setupSchemaSetMocks('test-tree.yang') when: 'replace list data method is invoked with empty list-node data fragment' def jsonData = '{"branch": []}' - objectUnderTest.replaceListNodeData(dataspaceName, anchorName, '/test-tree', jsonData) + objectUnderTest.replaceListNodeData(dataspaceName, anchorName, '/test-tree', jsonData, observedTimestamp) then: 'invalid data exception is thrown' thrown(DataValidationException) } @@ -214,11 +219,11 @@ class CpsDataServiceImplSpec extends Specification { given: 'schema set for given anchor and dataspace references test-tree model' setupSchemaSetMocks('test-tree.yang') when: 'delete list data method is invoked with list-node json data' - objectUnderTest.deleteListNodeData(dataspaceName, anchorName, '/test-tree/branch') + objectUnderTest.deleteListNodeData(dataspaceName, anchorName, '/test-tree/branch', observedTimestamp) then: 'the persistence service method is invoked with correct parameters' 1 * mockCpsDataPersistenceService.deleteListDataNodes(dataspaceName, anchorName, '/test-tree/branch') and: 'data updated event is sent to notification service' - 1 * mockNotificationService.processDataUpdatedEvent(dataspaceName, anchorName) + 1 * mockNotificationService.processDataUpdatedEvent(dataspaceName, anchorName, observedTimestamp) } def setupSchemaSetMocks(String... yangResources) { diff --git a/cps-service/src/test/groovy/org/onap/cps/api/impl/E2ENetworkSliceSpec.groovy b/cps-service/src/test/groovy/org/onap/cps/api/impl/E2ENetworkSliceSpec.groovy index fec8b7fa79..eefa86e903 100755 --- a/cps-service/src/test/groovy/org/onap/cps/api/impl/E2ENetworkSliceSpec.groovy +++ b/cps-service/src/test/groovy/org/onap/cps/api/impl/E2ENetworkSliceSpec.groovy @@ -22,6 +22,7 @@ package org.onap.cps.api.impl +import java.time.OffsetDateTime import org.onap.cps.TestUtils import org.onap.cps.api.CpsAdminService import org.onap.cps.notification.NotificationService @@ -44,6 +45,7 @@ class E2ENetworkSliceSpec extends Specification { def dataspaceName = 'someDataspace' def anchorName = 'someAnchor' def schemaSetName = 'someSchemaSet' + def noTimestamp = null def setup() { cpsDataServiceImpl.cpsDataPersistenceService = mockDataStoreService @@ -92,7 +94,7 @@ class E2ENetworkSliceSpec extends Specification { YangTextSchemaSourceSetBuilder.of(yangResourcesNameToContentMap) mockModuleStoreService.getYangSchemaResources(dataspaceName, schemaSetName) >> schemaContext when: 'saveData method is invoked' - cpsDataServiceImpl.saveData(dataspaceName, anchorName, jsonData) + cpsDataServiceImpl.saveData(dataspaceName, anchorName, jsonData, noTimestamp) then: 'Parameters are validated and processing is delegated to persistence service' 1 * mockDataStoreService.storeDataNode('someDataspace', 'someAnchor', _) >> { args -> dataNodeStored = args[2]} @@ -124,7 +126,7 @@ class E2ENetworkSliceSpec extends Specification { mockYangTextSchemaSourceSetCache.get('someDataspace', 'someSchemaSet') >> YangTextSchemaSourceSetBuilder.of(yangResourcesNameToContentMap) mockModuleStoreService.getYangSchemaResources('someDataspace', 'someSchemaSet') >> schemaContext when: 'saveData method is invoked' - cpsDataServiceImpl.saveData('someDataspace', 'someAnchor', jsonData) + cpsDataServiceImpl.saveData('someDataspace', 'someAnchor', jsonData, noTimestamp) then: 'parameters are validated and processing is delegated to persistence service' 1 * mockDataStoreService.storeDataNode('someDataspace', 'someAnchor', _) >> { args -> dataNodeStored = args[2]} diff --git a/cps-service/src/test/groovy/org/onap/cps/notification/CpsDataUpdateEventFactorySpec.groovy b/cps-service/src/test/groovy/org/onap/cps/notification/CpsDataUpdateEventFactorySpec.groovy index 2ce77bd1a8..aa0c7c0b39 100644 --- a/cps-service/src/test/groovy/org/onap/cps/notification/CpsDataUpdateEventFactorySpec.groovy +++ b/cps-service/src/test/groovy/org/onap/cps/notification/CpsDataUpdateEventFactorySpec.groovy @@ -1,12 +1,13 @@ /* * ============LICENSE_START======================================================= - * Copyright (C) 2021 Bell Canada. All rights reserved. + * Copyright (c) 2021 Bell Canada. * ================================================================================ * 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. @@ -19,6 +20,9 @@ package org.onap.cps.notification +import java.time.OffsetDateTime +import java.time.format.DateTimeFormatter +import org.onap.cps.utils.DateTimeUtility import org.onap.cps.api.CpsAdminService import org.onap.cps.api.CpsDataService import org.onap.cps.event.model.Data @@ -28,8 +32,6 @@ import org.onap.cps.spi.model.DataNodeBuilder import org.springframework.util.StringUtils import spock.lang.Specification -import java.time.format.DateTimeFormatter - class CpsDataUpdateEventFactorySpec extends Specification { def mockCpsDataService = Mock(CpsDataService) @@ -42,7 +44,7 @@ class CpsDataUpdateEventFactorySpec extends Specification { def mySchemasetName = 'my-schemaset-name' def dateTimeFormat = 'yyyy-MM-dd\'T\'HH:mm:ss.SSSZ' - def 'Create a CPS data updated event successfully.'() { + def 'Create a CPS data updated event successfully: #scenario'() { given: 'cps admin service is able to return anchor details' mockCpsAdminService.getAnchor(myDataspaceName, myAnchorName) >> @@ -51,12 +53,14 @@ class CpsDataUpdateEventFactorySpec extends Specification { def xpath = '/' def dataNode = new DataNodeBuilder().withXpath(xpath).withLeaves(['leafName': 'leafValue']).build() mockCpsDataService.getDataNode( - myDataspaceName, myAnchorName, xpath, FetchDescendantsOption.INCLUDE_ALL_DESCENDANTS) >> dataNode + myDataspaceName, myAnchorName, xpath, FetchDescendantsOption.INCLUDE_ALL_DESCENDANTS) >> dataNode when: 'CPS data updated event is created' - def cpsDataUpdatedEvent = objectUnderTest.createCpsDataUpdatedEvent(myDataspaceName, myAnchorName) + def cpsDataUpdatedEvent = objectUnderTest.createCpsDataUpdatedEvent(myDataspaceName, + myAnchorName, DateTimeUtility.toOffsetDateTime(inputObservedTimestamp)) + + then: 'CPS data updated event is created with correct envelope' - then: 'CPS data updated event is created with expected values' with(cpsDataUpdatedEvent) { type == 'org.onap.cps.data-updated-event' source == new URI('urn:cps:org.onap.cps') @@ -64,13 +68,24 @@ class CpsDataUpdateEventFactorySpec extends Specification { StringUtils.hasText(id) content != null } + and: 'correct content' with(cpsDataUpdatedEvent.content) { assert isExpectedDateTimeFormat(observedTimestamp): "$observedTimestamp is not in $dateTimeFormat format" - anchorName == myAnchorName - dataspaceName == myDataspaceName - schemaSetName == mySchemasetName - data == new Data().withAdditionalProperty('leafName', 'leafValue') + if (inputObservedTimestamp != null) + assert observedTimestamp == inputObservedTimestamp + else + assert OffsetDateTime.now().minusSeconds(20).isBefore( + DateTimeUtility.toOffsetDateTime(observedTimestamp)) + assert anchorName == myAnchorName + assert dataspaceName == myDataspaceName + assert schemaSetName == mySchemasetName + assert data == new Data().withAdditionalProperty('leafName', 'leafValue') } + where: + scenario | inputObservedTimestamp + 'with observed timestamp -0400' | '2021-01-01T23:00:00.345-0400' + 'with observed timestamp +0400' | '2021-01-01T23:00:00.345+0400' + 'missing observed timestamp' | null } def isExpectedDateTimeFormat(String observedTimestamp) { diff --git a/cps-service/src/test/groovy/org/onap/cps/notification/NotificationServiceSpec.groovy b/cps-service/src/test/groovy/org/onap/cps/notification/NotificationServiceSpec.groovy index ab727671e1..875113d225 100644 --- a/cps-service/src/test/groovy/org/onap/cps/notification/NotificationServiceSpec.groovy +++ b/cps-service/src/test/groovy/org/onap/cps/notification/NotificationServiceSpec.groovy @@ -20,6 +20,7 @@ package org.onap.cps.notification +import java.time.OffsetDateTime import org.onap.cps.config.AsyncConfig import org.onap.cps.event.model.CpsDataUpdatedEvent import org.spockframework.spring.SpringBean @@ -55,12 +56,13 @@ class NotificationServiceSpec extends Specification { @Shared def myDataspacePublishedName = 'my-dataspace-published' def myAnchorName = 'my-anchorname' + def myObservedTimestamp = OffsetDateTime.now() def 'Skip sending notification when disabled.'() { given: 'notification is disabled' spyNotificationProperties.isEnabled() >> false when: 'dataUpdatedEvent is received' - objectUnderTest.processDataUpdatedEvent(myDataspacePublishedName, myAnchorName) + objectUnderTest.processDataUpdatedEvent(myDataspacePublishedName, myAnchorName, myObservedTimestamp) then: 'the notification is not sent' 0 * mockNotificationPublisher.sendNotification(_) } @@ -70,10 +72,11 @@ class NotificationServiceSpec extends Specification { spyNotificationProperties.isEnabled() >> true and: 'event factory can create event successfully' def cpsDataUpdatedEvent = new CpsDataUpdatedEvent() - mockCpsDataUpdatedEventFactory.createCpsDataUpdatedEvent(dataspaceName, myAnchorName) >> cpsDataUpdatedEvent + mockCpsDataUpdatedEventFactory.createCpsDataUpdatedEvent(dataspaceName, myAnchorName, myObservedTimestamp) >> + cpsDataUpdatedEvent when: 'dataUpdatedEvent is received' - def future = objectUnderTest.processDataUpdatedEvent(dataspaceName, myAnchorName) - and: 'wait for async processing is completed' + def future = objectUnderTest.processDataUpdatedEvent(dataspaceName, myAnchorName, myObservedTimestamp) + and: 'wait for async processing to complete' future.get(10, TimeUnit.SECONDS) then: 'async process completed successfully' future.isDone() @@ -89,11 +92,11 @@ class NotificationServiceSpec extends Specification { given: 'notification is enabled' spyNotificationProperties.isEnabled() >> true and: 'event factory can not create event successfully' - mockCpsDataUpdatedEventFactory.createCpsDataUpdatedEvent(myDataspacePublishedName, myAnchorName) >> + mockCpsDataUpdatedEventFactory.createCpsDataUpdatedEvent(myDataspacePublishedName, myAnchorName, myObservedTimestamp) >> { throw new Exception("Could not create event") } when: 'event is sent for processing' - def future = objectUnderTest.processDataUpdatedEvent(myDataspacePublishedName, myAnchorName) - and: 'wait for async processing is completed' + def future = objectUnderTest.processDataUpdatedEvent(myDataspacePublishedName, myAnchorName, myObservedTimestamp) + and: 'wait for async processing to complete' future.get(10, TimeUnit.SECONDS) then: 'async process completed successfully' future.isDone() diff --git a/cps-service/src/test/java/org/onap/cps/utils/DateTimeUtility.java b/cps-service/src/test/java/org/onap/cps/utils/DateTimeUtility.java new file mode 100644 index 0000000000..f8d709647c --- /dev/null +++ b/cps-service/src/test/java/org/onap/cps/utils/DateTimeUtility.java @@ -0,0 +1,40 @@ +/* + * ============LICENSE_START======================================================= + * Copyright (c) 2021 Bell Canada. + * ================================================================================ + * 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.time.OffsetDateTime; +import java.time.format.DateTimeFormatter; +import org.springframework.util.StringUtils; + +public interface DateTimeUtility { + + String ISO_TIMESTAMP_PATTERN = "yyyy-MM-dd'T'HH:mm:ss.SSSZ"; + DateTimeFormatter ISO_TIMESTAMP_FORMATTER = DateTimeFormatter.ofPattern(ISO_TIMESTAMP_PATTERN); + + static OffsetDateTime toOffsetDateTime(String datetTimestampAsString) { + return ! StringUtils.hasLength(datetTimestampAsString) + ? null : OffsetDateTime.parse(datetTimestampAsString, ISO_TIMESTAMP_FORMATTER); + } + + static String toString(OffsetDateTime offsetDateTime) { + return offsetDateTime != null ? ISO_TIMESTAMP_FORMATTER.format(offsetDateTime) : null; + } +} -- cgit 1.2.3-korg