Add optional observed timestamp in the cps data api

- Added optional query parameter in cps data endpoints
- Updated service layer and notification to use observedTimestamp

Note:
- NCMP REST endpoints are not updated as a part of this patch
- NCMP does not sent observed timestamp when using cps data services

Issue-ID: CPS-477
Signed-off-by: puthuparambil.aditya <aditya.puthuparambil@bell.ca>
Change-Id: I1f92da3da7b3a13c45405fdf44e5fef861991d9a
Signed-off-by: Renu Kumari <renu.kumari@bell.ca>
diff --git a/cps-rest/src/main/java/org/onap/cps/rest/controller/DataRestController.java b/cps-rest/src/main/java/org/onap/cps/rest/controller/DataRestController.java
index 5c79472..0e2050e 100755
--- a/cps-rest/src/main/java/org/onap/cps/rest/controller/DataRestController.java
+++ b/cps-rest/src/main/java/org/onap/cps/rest/controller/DataRestController.java
@@ -1,6 +1,6 @@
 /*
  *  ============LICENSE_START=======================================================
- *  Copyright (C) 2020 Bell Canada.
+ *  Copyright (C) 2020-2021 Bell Canada.
  *  Modifications Copyright (C) 2021 Pantheon.tech
  *  Modifications Copyright (C) 2021 Nordix Foundation
  *  ================================================================================
@@ -9,6 +9,7 @@
  *  You may obtain a copy of the License at
  *
  *        http://www.apache.org/licenses/LICENSE-2.0
+ *
  *  Unless required by applicable law or agreed to in writing, software
  *  distributed under the License is distributed on an "AS IS" BASIS,
  *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@@ -21,8 +22,10 @@
 
 package org.onap.cps.rest.controller;
 
-import javax.validation.Valid;
-import javax.validation.constraints.NotNull;
+import java.time.OffsetDateTime;
+import java.time.format.DateTimeFormatter;
+import javax.validation.ValidationException;
+import org.apache.commons.lang3.StringUtils;
 import org.onap.cps.api.CpsDataService;
 import org.onap.cps.rest.api.CpsDataApi;
 import org.onap.cps.spi.FetchDescendantsOption;
@@ -38,25 +41,29 @@
 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 @@
 
     @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));
+        }
+    }
 }
diff --git a/cps-rest/src/main/java/org/onap/cps/rest/exceptions/CpsRestExceptionHandler.java b/cps-rest/src/main/java/org/onap/cps/rest/exceptions/CpsRestExceptionHandler.java
index 143ad8b..d790e08 100755
--- a/cps-rest/src/main/java/org/onap/cps/rest/exceptions/CpsRestExceptionHandler.java
+++ b/cps-rest/src/main/java/org/onap/cps/rest/exceptions/CpsRestExceptionHandler.java
@@ -22,6 +22,7 @@
 package org.onap.cps.rest.exceptions;
 
 import javax.servlet.http.HttpServletRequest;
+import javax.validation.ValidationException;
 import lombok.extern.slf4j.Slf4j;
 import org.onap.cps.rest.controller.AdminRestController;
 import org.onap.cps.rest.controller.DataRestController;
@@ -67,6 +68,11 @@
         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) {
diff --git a/cps-rest/src/main/resources/static/components.yml b/cps-rest/src/main/resources/static/components.yml
index 51a49a6..75a6f99 100644
--- a/cps-rest/src/main/resources/static/components.yml
+++ b/cps-rest/src/main/resources/static/components.yml
@@ -158,6 +158,14 @@
       schema:
         type: boolean
         default: false
+    observedTimestampInQuery:
+      name: observed-timestamp
+      in: query
+      description: observed-timestamp
+      required: false
+      schema:
+        type: string
+        example: '2021-03-21T00:10:34.030-0100'
 
   responses:
     NotFound:
diff --git a/cps-rest/src/main/resources/static/cpsData.yml b/cps-rest/src/main/resources/static/cpsData.yml
index 9c4f333..75d9544 100644
--- a/cps-rest/src/main/resources/static/cpsData.yml
+++ b/cps-rest/src/main/resources/static/cpsData.yml
@@ -55,6 +55,7 @@
       - $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 @@
       - $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 @@
       - $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 @@
       - $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 @@
       - $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 @@
       - $ref: 'components.yml#/components/parameters/dataspaceNameInPath'
       - $ref: 'components.yml#/components/parameters/anchorNameInPath'
       - $ref: 'components.yml#/components/parameters/xpathInQuery'
+      - $ref: 'components.yml#/components/parameters/observedTimestampInQuery'
     requestBody:
       required: true
       content:
diff --git a/cps-rest/src/test/groovy/org/onap/cps/rest/controller/DataRestControllerSpec.groovy b/cps-rest/src/test/groovy/org/onap/cps/rest/controller/DataRestControllerSpec.groovy
index d3d42e3..1d51ec4 100755
--- a/cps-rest/src/test/groovy/org/onap/cps/rest/controller/DataRestControllerSpec.groovy
+++ b/cps-rest/src/test/groovy/org/onap/cps/rest/controller/DataRestControllerSpec.groovy
@@ -30,13 +30,10 @@
 import static org.springframework.test.web.servlet.request.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 @@
     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 @@
             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 @@
         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 @@
             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 @@
             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 @@
             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 @@
             '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 @@
             'some xpath by parent' | '/some/xpath' || '/some/xpath'
     }
 
+    def 'Replace data node tree with observedTimestamp.'() {
+        given: 'json data'
+            def jsonData = 'json data'
+            def endpoint = "$dataNodeBaseEndpoint/anchors/$anchorName/nodes"
+        when: 'put request is performed'
+            def response =
+                mvc.perform(
+                    put(endpoint)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(jsonData)
+                        .param('xpath', '')
+                        .param('observed-timestamp', observedTimestamp))
+                    .andReturn().response
+        then: 'the service method is invoked with expected parameters'
+            expectedApiCount * mockCpsDataService.replaceNodeTree(dataspaceName, anchorName, '/', jsonData,
+                { it == DateTimeUtility.toOffsetDateTime(observedTimestamp) })
+        and: 'response status indicates success'
+            response.status == expectedHttpStatus.value()
+        where:
+            scenario                          | observedTimestamp              || expectedApiCount | expectedHttpStatus
+            'with observed-timestamp'         | '2021-03-03T23:59:59.999-0400' || 1                | HttpStatus.OK
+            'with invalid observed-timestamp' | 'invalid'                      || 0                | HttpStatus.BAD_REQUEST
+    }
+
     def 'Replace list node child elements.'() {
         given: 'parent node xpath and json data inputs'
             def parentNodeXpath = 'parent node xpath'
             def jsonData = 'json data'
         when: 'patch is invoked list-node endpoint'
-            def response = mvc.perform(
-                    patch("$dataNodeBaseEndpoint/anchors/$anchorName/list-node")
-                            .contentType(MediaType.APPLICATION_JSON)
-                            .param('xpath', parentNodeXpath)
-                            .content(jsonData)
-            ).andReturn().response
+            def patchRequestBuilder = patch("$dataNodeBaseEndpoint/anchors/$anchorName/list-node")
+                .contentType(MediaType.APPLICATION_JSON)
+                .param('xpath', parentNodeXpath)
+                .content(jsonData)
+            if (observedTimestamp != null)
+                patchRequestBuilder.param('observed-timestamp', observedTimestamp)
+            def response = mvc.perform(patchRequestBuilder).andReturn().response
         then: 'a success response is returned'
-            response.status == HttpStatus.OK.value()
-        then: 'the java API was called with the correct parameters'
-            1 * mockCpsDataService.replaceListNodeData(dataspaceName, anchorName, parentNodeXpath, jsonData)
+            response.status == expectedHttpStatus.value()
+        and: 'the java API was called with the correct parameters'
+            expectedApiCount * mockCpsDataService.replaceListNodeData(dataspaceName, anchorName, parentNodeXpath, jsonData,
+                { it == DateTimeUtility.toOffsetDateTime(observedTimestamp) })
+        where:
+            scenario                          | observedTimestamp              || expectedApiCount | expectedHttpStatus
+            'with observed-timestamp'         | '2021-03-03T23:59:59.999-0400' || 1                | HttpStatus.OK
+            'without observed-timestamp'      | null                           || 1                | HttpStatus.OK
+            'with invalid observed-timestamp' | 'invalid'                      || 0                | HttpStatus.BAD_REQUEST
     }
 
-    def 'Delete list node child elements.'() {
+    def 'Delete list node child elements. #scenario'() {
         given: 'list node xpath'
             def listNodeXpath = 'list node xpath'
         when: 'delete is invoked list-node endpoint'
-            def response = mvc.perform(
-                    delete("$dataNodeBaseEndpoint/anchors/$anchorName/list-node")
-                        .param('xpath', listNodeXpath)
-            ).andReturn().response
+            def deleteRequestBuilder = delete("$dataNodeBaseEndpoint/anchors/$anchorName/list-node")
+                .param('xpath', listNodeXpath)
+            if (observedTimestamp != null)
+                deleteRequestBuilder.param('observed-timestamp', observedTimestamp)
+            def response = mvc.perform(deleteRequestBuilder).andReturn().response
         then: 'a success response is returned'
-            response.status == HttpStatus.NO_CONTENT.value()
-        then: 'the java API was called with the correct parameters'
-            1 * mockCpsDataService.deleteListNodeData(dataspaceName, anchorName, listNodeXpath)
+            response.status == expectedHttpStatus.value()
+        and: 'the java API was called with the correct parameters'
+            expectedApiCount * mockCpsDataService.deleteListNodeData(dataspaceName, anchorName, listNodeXpath,
+                { it == DateTimeUtility.toOffsetDateTime(observedTimestamp) })
+        where:
+            scenario                          | observedTimestamp              || expectedApiCount | expectedHttpStatus
+            'with observed-timestamp'         | '2021-03-03T23:59:59.999-0400' || 1                | HttpStatus.NO_CONTENT
+            'without observed-timestamp'      | null                           || 1                | HttpStatus.NO_CONTENT
+            'with invalid observed-timestamp' | 'invalid'                      || 0                | HttpStatus.BAD_REQUEST
     }
 }
diff --git a/cps-rest/src/test/groovy/org/onap/cps/rest/exceptions/CpsRestExceptionHandlerSpec.groovy b/cps-rest/src/test/groovy/org/onap/cps/rest/exceptions/CpsRestExceptionHandlerSpec.groovy
index f44518d..079a59c 100644
--- a/cps-rest/src/test/groovy/org/onap/cps/rest/exceptions/CpsRestExceptionHandlerSpec.groovy
+++ b/cps-rest/src/test/groovy/org/onap/cps/rest/exceptions/CpsRestExceptionHandlerSpec.groovy
@@ -111,7 +111,7 @@
             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 @@
             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 @@
      * 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 @@
 
     def performTestRequest() {
         return mvc.perform(
-                get("$basePath/v1/dataspaces/dataspace-name/anchors"))
-                .andReturn().response
+            get("$basePath/v1/dataspaces/dataspace-name/anchors"))
+            .andReturn().response
     }
 
     static void assertTestResponse(response, expectedStatus, expectedErrorMessage, expectedErrorDetails) {
diff --git a/cps-rest/src/test/java/org/onap/cps/utils/DateTimeUtility.java b/cps-rest/src/test/java/org/onap/cps/utils/DateTimeUtility.java
new file mode 100644
index 0000000..f8d7096
--- /dev/null
+++ b/cps-rest/src/test/java/org/onap/cps/utils/DateTimeUtility.java
@@ -0,0 +1,40 @@
+/*
+ * ============LICENSE_START=======================================================
+ * Copyright (c) 2021 Bell Canada.
+ * ================================================================================
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ * ============LICENSE_END=========================================================
+ */
+
+package org.onap.cps.utils;
+
+import java.time.OffsetDateTime;
+import java.time.format.DateTimeFormatter;
+import org.springframework.util.StringUtils;
+
+public interface DateTimeUtility {
+
+    String ISO_TIMESTAMP_PATTERN = "yyyy-MM-dd'T'HH:mm:ss.SSSZ";
+    DateTimeFormatter ISO_TIMESTAMP_FORMATTER = DateTimeFormatter.ofPattern(ISO_TIMESTAMP_PATTERN);
+
+    static OffsetDateTime toOffsetDateTime(String datetTimestampAsString) {
+        return ! StringUtils.hasLength(datetTimestampAsString)
+            ? null : OffsetDateTime.parse(datetTimestampAsString, ISO_TIMESTAMP_FORMATTER);
+    }
+
+    static String toString(OffsetDateTime offsetDateTime) {
+        return offsetDateTime != null ? ISO_TIMESTAMP_FORMATTER.format(offsetDateTime) : null;
+    }
+}