Add optional observed timestamp in the cps data api 18/123518/11
authoraditya puthuparambil <aditya.puthuparambil@bell.ca>
Tue, 24 Aug 2021 16:44:34 +0000 (17:44 +0100)
committerRenu Kumari <renu.kumari@bell.ca>
Wed, 25 Aug 2021 19:01:06 +0000 (15:01 -0400)
- 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 <aditya.puthuparambil@bell.ca>
Change-Id: I1f92da3da7b3a13c45405fdf44e5fef861991d9a
Signed-off-by: Renu Kumari <renu.kumari@bell.ca>
18 files changed:
cps-ncmp-service/src/main/java/org/onap/cps/ncmp/api/impl/NetworkCmProxyDataServiceImpl.java
cps-ncmp-service/src/test/groovy/org/onap/cps/ncmp/api/impl/NetworkCmProxyDataServiceImplSpec.groovy
cps-rest/src/main/java/org/onap/cps/rest/controller/DataRestController.java
cps-rest/src/main/java/org/onap/cps/rest/exceptions/CpsRestExceptionHandler.java
cps-rest/src/main/resources/static/components.yml
cps-rest/src/main/resources/static/cpsData.yml
cps-rest/src/test/groovy/org/onap/cps/rest/controller/DataRestControllerSpec.groovy
cps-rest/src/test/groovy/org/onap/cps/rest/exceptions/CpsRestExceptionHandlerSpec.groovy
cps-rest/src/test/java/org/onap/cps/utils/DateTimeUtility.java [new file with mode: 0644]
cps-service/src/main/java/org/onap/cps/api/CpsDataService.java
cps-service/src/main/java/org/onap/cps/api/impl/CpsDataServiceImpl.java
cps-service/src/main/java/org/onap/cps/notification/CpsDataUpdatedEventFactory.java
cps-service/src/main/java/org/onap/cps/notification/NotificationService.java
cps-service/src/test/groovy/org/onap/cps/api/impl/CpsDataServiceImplSpec.groovy
cps-service/src/test/groovy/org/onap/cps/api/impl/E2ENetworkSliceSpec.groovy
cps-service/src/test/groovy/org/onap/cps/notification/CpsDataUpdateEventFactorySpec.groovy
cps-service/src/test/groovy/org/onap/cps/notification/NotificationServiceSpec.groovy
cps-service/src/test/java/org/onap/cps/utils/DateTimeUtility.java [new file with mode: 0644]

index 235030a..b5a5914 100755 (executable)
@@ -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());
             }
index 0760167..45fa0af 100644 (file)
@@ -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'
     }
index 5c79472..0e2050e 100755 (executable)
@@ -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.
 
 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<String> 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<String> 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<Object> 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<Object> replaceNode(final String dataspaceName,
-        final String anchorName, @Valid final String jsonData, @Valid final String parentNodeXpath) {
-        cpsDataService.replaceNodeTree(dataspaceName, anchorName, parentNodeXpath, jsonData);
+    public ResponseEntity<Object> 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<String> replaceListNodeElements(@NotNull @Valid final String parentNodeXpath,
-        final String dataspaceName, final String anchorName, @Valid final String jsonData) {
-        cpsDataService.replaceListNodeData(dataspaceName, anchorName, parentNodeXpath, jsonData);
+    public ResponseEntity<String> 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<Void> 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));
+        }
+    }
 }
index 143ad8b..d790e08 100755 (executable)
@@ -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<Object> handleBadRequestExceptions(final ValidationException validationException) {
+        return buildErrorResponse(HttpStatus.BAD_REQUEST, validationException);
+    }
+
     @ExceptionHandler({NotFoundInDataspaceException.class, DataNodeNotFoundException.class})
     public static ResponseEntity<Object> handleNotFoundExceptions(final CpsException exception,
         final HttpServletRequest request) {
index 51a49a6..75a6f99 100644 (file)
@@ -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:
index 9c4f333..75d9544 100644 (file)
@@ -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:
index d3d42e3..1d51ec4 100755 (executable)
@@ -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
     }
 }
index f44518d..079a59c 100644 (file)
@@ -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 (file)
index 0000000..f8d7096
--- /dev/null
@@ -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;
+    }
+}
index 2583e99..31a7517 100644 (file)
@@ -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);
 }
index 8989dc8..7b3567e 100755 (executable)
@@ -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<DataNode> 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<DataNode> 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<DataNode> 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);
         }
index e0c8fe7..85e5aba 100644 (file)
@@ -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;
     }
 }
index 4745739..029efbe 100644 (file)
@@ -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<Void> processDataUpdatedEvent(final String dataspaceName, final String anchorName) {
+    public Future<Void> 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);
             }
index 97eac5a..6a0a464 100644 (file)
@@ -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) {
index fec8b7f..eefa86e 100755 (executable)
@@ -22,6 +22,7 @@
 \r
 package org.onap.cps.api.impl\r
 \r
+import java.time.OffsetDateTime\r
 import org.onap.cps.TestUtils\r
 import org.onap.cps.api.CpsAdminService\r
 import org.onap.cps.notification.NotificationService\r
@@ -44,6 +45,7 @@ class E2ENetworkSliceSpec extends Specification {
     def dataspaceName = 'someDataspace'\r
     def anchorName = 'someAnchor'\r
     def schemaSetName = 'someSchemaSet'\r
+    def noTimestamp = null\r
 \r
     def setup() {\r
         cpsDataServiceImpl.cpsDataPersistenceService = mockDataStoreService\r
@@ -92,7 +94,7 @@ class E2ENetworkSliceSpec extends Specification {
                     YangTextSchemaSourceSetBuilder.of(yangResourcesNameToContentMap)\r
             mockModuleStoreService.getYangSchemaResources(dataspaceName, schemaSetName) >> schemaContext\r
         when: 'saveData method is invoked'\r
-            cpsDataServiceImpl.saveData(dataspaceName, anchorName, jsonData)\r
+            cpsDataServiceImpl.saveData(dataspaceName, anchorName, jsonData, noTimestamp)\r
         then: 'Parameters are validated and processing is delegated to persistence service'\r
             1 * mockDataStoreService.storeDataNode('someDataspace', 'someAnchor', _) >>\r
                     { args -> dataNodeStored = args[2]}\r
@@ -124,7 +126,7 @@ class E2ENetworkSliceSpec extends Specification {
             mockYangTextSchemaSourceSetCache.get('someDataspace', 'someSchemaSet') >> YangTextSchemaSourceSetBuilder.of(yangResourcesNameToContentMap)\r
             mockModuleStoreService.getYangSchemaResources('someDataspace', 'someSchemaSet') >> schemaContext\r
         when: 'saveData method is invoked'\r
-            cpsDataServiceImpl.saveData('someDataspace', 'someAnchor', jsonData)\r
+            cpsDataServiceImpl.saveData('someDataspace', 'someAnchor', jsonData, noTimestamp)\r
         then: 'parameters are validated and processing is delegated to persistence service'\r
             1 * mockDataStoreService.storeDataNode('someDataspace', 'someAnchor', _) >>\r
                     { args -> dataNodeStored = args[2]}\r
index 2ce77bd..aa0c7c0 100644 (file)
@@ -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) {
index ab72767..875113d 100644 (file)
@@ -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 (file)
index 0000000..f8d7096
--- /dev/null
@@ -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;
+    }
+}