Add APIs to control notification subscription 90/139790/20
authorrajesh.kumar <rk00747546@techmahindra.com>
Tue, 24 Dec 2024 08:10:06 +0000 (13:40 +0530)
committerrajesh.kumar <rk00747546@techmahindra.com>
Wed, 5 Mar 2025 05:49:34 +0000 (11:19 +0530)
  - Add API for notification subscription
  - Add API for notification unsubscription
  - Add API for getting notification subscription information

Issue-ID: CPS-2428
Change-Id: I56c34400dc73c71b936a51260efd241224dccdba
Signed-off-by: rajesh.kumar <rk00747546@techmahindra.com>
15 files changed:
cps-rest/docs/openapi/components.yml
cps-rest/docs/openapi/cpsAdminV2.yml
cps-rest/docs/openapi/openapi.yml
cps-rest/src/main/java/org/onap/cps/rest/controller/AdminRestController.java
cps-rest/src/test/groovy/org/onap/cps/rest/controller/AdminRestControllerSpec.groovy
cps-rest/src/test/groovy/org/onap/cps/rest/exceptions/CpsRestExceptionHandlerSpec.groovy
cps-service/src/main/java/org/onap/cps/api/CpsNotificationService.java [new file with mode: 0644]
cps-service/src/main/java/org/onap/cps/events/CpsDataUpdateEventsService.java
cps-service/src/main/java/org/onap/cps/impl/CpsNotificationServiceImpl.java [new file with mode: 0644]
cps-service/src/main/java/org/onap/cps/init/CpsNotificationSubscriptionModelLoader.java
cps-service/src/test/groovy/org/onap/cps/events/CpsDataUpdateEventsServiceSpec.groovy
cps-service/src/test/groovy/org/onap/cps/impl/CpsNotificationServiceImplSpec.groovy [new file with mode: 0644]
cps-service/src/test/groovy/org/onap/cps/init/CpsNotificationSubscriptionModelLoaderSpec.groovy
cps-service/src/test/resources/cps-notification-subscriptions@2024-07-03.yang [new file with mode: 0644]
docs/api/swagger/cps/openapi.yaml

index 1a7e430..43a3118 100644 (file)
@@ -1,7 +1,7 @@
 # ============LICENSE_START=======================================================
 # Copyright (c) 2021-2022 Bell Canada.
 # Modifications Copyright (C) 2021-2023 Nordix Foundation
-# Modifications Copyright (C) 2022-2024 TechMahindra Ltd.
+# Modifications Copyright (C) 2022-2025 TechMahindra Ltd.
 # Modifications Copyright (C) 2022 Deutsche Telekom AG
 # ================================================================================
 # Licensed under the Apache License, Version 2.0 (the "License");
@@ -157,7 +157,12 @@ components:
             name: "Funny"
           target-data:
             name: "Comic"
-
+    NotificationSubscriptionsDataSample:
+      value:
+        cps-notification-subscriptions:dataspaces:
+          dataspace:
+            - name: dataspace01
+            - name: dataspace02
   parameters:
     dataspaceNameInQuery:
       name: dataspace-name
@@ -236,6 +241,19 @@ components:
           value: /shops/bookstore
         list attributes xpath:
           value: /shops/bookstore/categories[@code=1]
+    notificationSubscriptionXpathInQuery:
+      name: xpath
+      in: query
+      description: For more details on xpath, please refer https://docs.onap.org/projects/onap-cps/en/latest/xpath.html
+      required: true
+      schema:
+        type: string
+        default: /dataspaces
+      examples:
+        subscription by dataspace xpath:
+          value: /dataspaces/dataspace[@name='dataspace01']
+        subscription by anchor xpath:
+          value: /dataspaces/dataspace[@name='dataspace01']/anchors/anchor[@name='anchor01']
     requiredXpathInQuery:
       name: xpath
       in: query
index e501ad8..af2572a 100644 (file)
@@ -1,5 +1,5 @@
 # ============LICENSE_START=======================================================
-# Copyright (C) 2022 TechMahindra Ltd.
+# Copyright (C) 2022-2025 TechMahindra Ltd.
 # ================================================================================
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -87,3 +87,75 @@ schemaSet:
         $ref: 'components.yml#/components/responses/Conflict'
       '500':
         $ref: 'components.yml#/components/responses/InternalServerError'
+
+notificationSubscription:
+  get:
+    description: Get cps notification subscription
+    tags:
+      - cps-admin
+    summary: Get cps notification subscription
+    operationId: getNotificationSubscription
+    parameters:
+      - $ref: 'components.yml#/components/parameters/notificationSubscriptionXpathInQuery'
+    responses:
+      '200':
+        description: OK
+        content:
+          application/json:
+            schema:
+              $ref: 'components.yml#/components/examples/NotificationSubscriptionsDataSample'
+      '400':
+        $ref: 'components.yml#/components/responses/BadRequest'
+      '403':
+        $ref: 'components.yml#/components/responses/Forbidden'
+      '409':
+        $ref: 'components.yml#/components/responses/Conflict'
+      '500':
+        $ref: 'components.yml#/components/responses/InternalServerError'
+  post:
+    description: Create cps notification subscription
+    tags:
+      - cps-admin
+    summary: Create cps notification subscription
+    operationId: createNotificationSubscription
+    parameters:
+      - $ref: 'components.yml#/components/parameters/notificationSubscriptionXpathInQuery'
+    requestBody:
+      required: true
+      content:
+        application/json:
+          schema:
+            type: object
+          examples:
+            dataSample:
+              $ref: 'components.yml#/components/examples/NotificationSubscriptionsDataSample'
+    responses:
+      '201':
+        $ref: 'components.yml#/components/responses/CreatedV2'
+      '400':
+        $ref: 'components.yml#/components/responses/BadRequest'
+      '403':
+        $ref: 'components.yml#/components/responses/Forbidden'
+      '409':
+        $ref: 'components.yml#/components/responses/Conflict'
+      '500':
+        $ref: 'components.yml#/components/responses/InternalServerError'
+  delete:
+    description: Delete cps notification subscription
+    tags:
+      - cps-admin
+    summary: Delete cps notification subscription
+    operationId: deleteNotificationSubscription
+    parameters:
+      - $ref: 'components.yml#/components/parameters/notificationSubscriptionXpathInQuery'
+    responses:
+      '204':
+        $ref: 'components.yml#/components/responses/NoContent'
+      '400':
+        $ref: 'components.yml#/components/responses/BadRequest'
+      '403':
+        $ref: 'components.yml#/components/responses/Forbidden'
+      '409':
+        $ref: 'components.yml#/components/responses/Conflict'
+      '500':
+        $ref: 'components.yml#/components/responses/InternalServerError'
\ No newline at end of file
index c85bf7c..09c454b 100644 (file)
@@ -2,7 +2,7 @@
 #  Copyright (C) 2021-2025 Nordix Foundation
 #  Modifications Copyright (C) 2021 Pantheon.tech
 #  Modifications Copyright (C) 2021 Bell Canada.
-#  Modifications Copyright (C) 2022-2024 TechMahindra Ltd.
+#  Modifications Copyright (C) 2022-2025 TechMahindra Ltd.
 #  ================================================================================
 #  Licensed under the Apache License, Version 2.0 (the "License");
 #  you may not use this file except in compliance with the License.
@@ -115,5 +115,8 @@ paths:
   /v2/dataspaces/{dataspace-name}/nodes/query:
     $ref: 'cpsQueryV2.yml#/nodesByDataspaceAndCpsPath'
 
+  /v2/notification-subscription:
+    $ref: 'cpsAdminV2.yml#/notificationSubscription'
+
 security:
   - basicAuth: []
index 4c6bd6c..01a9746 100755 (executable)
@@ -3,7 +3,7 @@
  *  Copyright (C) 2020-2025 Nordix Foundation
  *  Modifications Copyright (C) 2020-2021 Bell Canada.
  *  Modifications Copyright (C) 2021 Pantheon.tech
- *  Modifications Copyright (C) 2022 TechMahindra Ltd.
+ *  Modifications Copyright (C) 2022-2025 TechMahindra Ltd.
  *  ================================================================================
  *  Licensed under the Apache License, Version 2.0 (the "License");
  *  you may not use this file except in compliance with the License.
@@ -31,11 +31,13 @@ import jakarta.validation.Valid;
 import jakarta.validation.constraints.NotNull;
 import java.util.Collection;
 import java.util.List;
+import java.util.Map;
 import java.util.stream.Collectors;
 import lombok.RequiredArgsConstructor;
 import org.onap.cps.api.CpsAnchorService;
 import org.onap.cps.api.CpsDataspaceService;
 import org.onap.cps.api.CpsModuleService;
+import org.onap.cps.api.CpsNotificationService;
 import org.onap.cps.api.model.Anchor;
 import org.onap.cps.api.model.Dataspace;
 import org.onap.cps.api.model.SchemaSet;
@@ -43,6 +45,7 @@ import org.onap.cps.rest.api.CpsAdminApi;
 import org.onap.cps.rest.model.AnchorDetails;
 import org.onap.cps.rest.model.DataspaceDetails;
 import org.onap.cps.rest.model.SchemaSetDetails;
+import org.onap.cps.utils.JsonObjectMapper;
 import org.springframework.http.HttpStatus;
 import org.springframework.http.ResponseEntity;
 import org.springframework.web.bind.annotation.RequestMapping;
@@ -58,6 +61,8 @@ public class AdminRestController implements CpsAdminApi {
     private final CpsModuleService cpsModuleService;
     private final CpsRestInputMapper cpsRestInputMapper;
     private final CpsAnchorService cpsAnchorService;
+    private final CpsNotificationService cpsNotificationService;
+    private final JsonObjectMapper jsonObjectMapper;
 
     /**
      * Create a dataspace.
@@ -280,4 +285,25 @@ public class AdminRestController implements CpsAdminApi {
         final DataspaceDetails dataspaceDetails = cpsRestInputMapper.toDataspaceDetails(dataspace);
         return new ResponseEntity<>(dataspaceDetails, HttpStatus.OK);
     }
+
+    @Override
+    public ResponseEntity<Void> createNotificationSubscription(final String xpath,
+                                                               final Object notificationSubscriptionAsJson) {
+        cpsNotificationService.createNotificationSubscription(
+                jsonObjectMapper.asJsonString(notificationSubscriptionAsJson), xpath);
+        return new ResponseEntity<>(HttpStatus.CREATED);
+    }
+
+    @Override
+    public ResponseEntity<Void> deleteNotificationSubscription(final String xpath) {
+        cpsNotificationService.deleteNotificationSubscription(xpath);
+        return new ResponseEntity<>(HttpStatus.NO_CONTENT);
+    }
+
+    @Override
+    public ResponseEntity<Object> getNotificationSubscription(final String xpath) {
+        final List<Map<String, Object>> dataMaps = cpsNotificationService.getNotificationSubscription(xpath);
+        return new ResponseEntity<>(jsonObjectMapper.asJsonString(dataMaps), HttpStatus.OK);
+    }
+
 }
index 0d18978..6d1ca40 100755 (executable)
@@ -3,7 +3,7 @@
  *  Copyright (C) 2020-2021 Pantheon.tech
  *  Modifications Copyright (C) 2020-2021 Bell Canada.
  *  Modifications Copyright (C) 2021-2025 Nordix Foundation
- *  Modifications Copyright (C) 2022 TechMahindra Ltd.
+ *  Modifications Copyright (C) 2022-2025 TechMahindra Ltd.
  *  ================================================================================
  *  Licensed under the Apache License, Version 2.0 (the "License");
  *  you may not use this file except in compliance with the License.
 
 package org.onap.cps.rest.controller
 
+import com.fasterxml.jackson.databind.ObjectMapper
+
+import static org.onap.cps.api.parameters.CascadeDeleteAllowed.CASCADE_DELETE_PROHIBITED
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put
+
 import org.mapstruct.factory.Mappers
 import org.onap.cps.api.CpsAnchorService
 import org.onap.cps.api.CpsDataspaceService
 import org.onap.cps.api.CpsModuleService
+import org.onap.cps.api.CpsNotificationService
 import org.onap.cps.api.exceptions.AlreadyDefinedException
 import org.onap.cps.api.exceptions.SchemaSetInUseException
 import org.onap.cps.api.model.Anchor
 import org.onap.cps.api.model.Dataspace
 import org.onap.cps.api.model.SchemaSet
+import org.onap.cps.utils.JsonObjectMapper
 import org.spockframework.spring.SpringBean
 import org.springframework.beans.factory.annotation.Autowired
 import org.springframework.beans.factory.annotation.Value
@@ -62,9 +73,16 @@ class AdminRestControllerSpec extends Specification {
     @SpringBean
     CpsAnchorService mockCpsAnchorService = Mock()
 
+    @SpringBean
+    CpsNotificationService mockCpsNotificationService = Mock()
+
     @SpringBean
     CpsRestInputMapper cpsRestInputMapper = Mappers.getMapper(CpsRestInputMapper)
 
+    @SpringBean
+    JsonObjectMapper jsonObjectMapper = new JsonObjectMapper(new ObjectMapper())
+
+
     @Autowired
     MockMvc mvc
 
@@ -393,6 +411,48 @@ class AdminRestControllerSpec extends Specification {
             response.status == HttpStatus.NO_CONTENT.value()
     }
 
+    def 'Add notification subscription'() {
+        given: 'an endpoint and its payload'
+            def notificationSubscriptionEndpoint = "$basePath/v2/notification-subscription"
+            def xpath = '/dataspaces'
+            def jsonPayload = '{"dataspace":[{"name":"ds01"}]}'
+        when: 'post request is performed'
+            def response =
+                mvc.perform(
+                        post(notificationSubscriptionEndpoint)
+                                .contentType(MediaType.APPLICATION_JSON)
+                                .content(jsonPayload))
+                        .andReturn().response
+        then: 'notification service method is invoked with expected parameter'
+            1 * mockCpsNotificationService.createNotificationSubscription(jsonPayload, xpath)
+        and: 'HTTP response code indicates success'
+            response.status == HttpStatus.CREATED.value()
+    }
+
+    def 'delete notification subscription'() {
+        given: 'an endpoint and xpath'
+            def notificationSubscriptionEndpoint = "$basePath/v2/notification-subscription"
+            def xpath = '/dataspaces'
+        when: 'delete request is performed'
+            def response = mvc.perform(delete(notificationSubscriptionEndpoint).param('xpath', xpath)).andReturn().response
+        then: 'notification service method is invoked with expected parameter'
+            1 * mockCpsNotificationService.deleteNotificationSubscription(xpath)
+        and: 'HTTP response code indicates success'
+            response.status == HttpStatus.NO_CONTENT.value()
+    }
+
+    def 'Get notification subscription.'() {
+        given: 'an endpoint and xpath'
+            def notificationSubscriptionEndpoint = "$basePath/v2/notification-subscription"
+            def xpath = '/dataspaces'
+        when: 'get notification subscription is invoked'
+            def response = mvc.perform(get(notificationSubscriptionEndpoint).param('xpath', xpath)).andReturn().response
+        then: 'HTTP response code indicates success'
+            response.status == HttpStatus.OK.value()
+        and: 'notification service is called with proper parameters'
+            1 *  mockCpsNotificationService.getNotificationSubscription(xpath)
+    }
+
     def createMultipartFile(filename, content) {
         return new MockMultipartFile("file", filename, "text/plain", content.getBytes())
     }
index f0fc4cc..4e1d27c 100644 (file)
@@ -3,7 +3,7 @@
  *  Copyright (C) 2020 Pantheon.tech
  *  Modifications Copyright (C) 2021-2023 Nordix Foundation
  *  Modifications Copyright (C) 2021 Bell Canada.
- *  Modifications Copyright (C) 2022 TechMahindra Ltd.
+ *  Modifications Copyright (C) 2022-2025 TechMahindra Ltd.
  *  Modifications Copyright (C) 2022 Deutsche Telekom AG
  *  ================================================================================
  *  Licensed under the Apache License, Version 2.0 (the "License");
@@ -30,6 +30,7 @@ import org.onap.cps.api.CpsDataspaceService
 import org.onap.cps.api.CpsAnchorService
 import org.onap.cps.api.CpsDataService
 import org.onap.cps.api.CpsModuleService
+import org.onap.cps.api.CpsNotificationService
 import org.onap.cps.api.CpsQueryService
 import org.onap.cps.rest.controller.CpsRestInputMapper
 import org.onap.cps.api.exceptions.AlreadyDefinedException
@@ -87,6 +88,9 @@ class CpsRestExceptionHandlerSpec extends Specification {
     @SpringBean
     PrefixResolver prefixResolver = Mock()
 
+    @SpringBean
+    CpsNotificationService mockCpsNotificationService = Mock()
+
     @Autowired
     MockMvc mvc
 
diff --git a/cps-service/src/main/java/org/onap/cps/api/CpsNotificationService.java b/cps-service/src/main/java/org/onap/cps/api/CpsNotificationService.java
new file mode 100644 (file)
index 0000000..ae43775
--- /dev/null
@@ -0,0 +1,35 @@
+/*
+ *  ============LICENSE_START=======================================================
+ *  Copyright (C) 2025 TechMahindra Ltd.
+ *  ================================================================================
+ *  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.api;
+
+import java.util.List;
+import java.util.Map;
+
+public interface CpsNotificationService {
+
+    void createNotificationSubscription(String notificationSubscriptionAsJson, String xpath);
+
+    void deleteNotificationSubscription(String xpath);
+
+    boolean isNotificationEnabled(String dataspaceName, String anchorName);
+
+    List<Map<String, Object>> getNotificationSubscription(String xpath);
+}
index f1b5ff8..50441ad 100644 (file)
@@ -1,6 +1,6 @@
 /*
  * ============LICENSE_START=======================================================
- * Copyright (C) 2024 TechMahindra Ltd.
+ * Copyright (C) 2024-2025 TechMahindra Ltd.
  * Copyright (C) 2024 Nordix Foundation.
  * ================================================================================
  * Licensed under the Apache License, Version 2.0 (the "License");
@@ -28,6 +28,7 @@ import java.util.HashMap;
 import java.util.Map;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
+import org.onap.cps.api.CpsNotificationService;
 import org.onap.cps.api.model.Anchor;
 import org.onap.cps.events.model.CpsDataUpdatedEvent;
 import org.onap.cps.events.model.Data;
@@ -43,6 +44,8 @@ public class CpsDataUpdateEventsService {
 
     private final EventsPublisher<CpsDataUpdatedEvent> eventsPublisher;
 
+    private final CpsNotificationService cpsNotificationService;
+
     @Value("${app.cps.data-updated.topic:cps-data-updated-events}")
     private String topicName;
 
@@ -63,7 +66,7 @@ public class CpsDataUpdateEventsService {
     @Timed(value = "cps.dataupdate.events.publish", description = "Time taken to publish Data Update event")
     public void publishCpsDataUpdateEvent(final Anchor anchor, final String xpath,
                                           final Operation operation, final OffsetDateTime observedTimestamp) {
-        if (notificationsEnabled && cpsChangeEventNotificationsEnabled) {
+        if (notificationsEnabled && cpsChangeEventNotificationsEnabled && isNotificationEnabledForAnchor(anchor)) {
             final CpsDataUpdatedEvent cpsDataUpdatedEvent = createCpsDataUpdatedEvent(anchor,
                     observedTimestamp, xpath, operation);
             final String updateEventId = anchor.getDataspaceName() + ":" + anchor.getName();
@@ -78,6 +81,10 @@ public class CpsDataUpdateEventsService {
         }
     }
 
+    private boolean isNotificationEnabledForAnchor(final Anchor anchor) {
+        return cpsNotificationService.isNotificationEnabled(anchor.getDataspaceName(), anchor.getName());
+    }
+
     private CpsDataUpdatedEvent createCpsDataUpdatedEvent(final Anchor anchor, final OffsetDateTime observedTimestamp,
                                                           final String xpath,
                                                           final Operation rootNodeOperation) {
diff --git a/cps-service/src/main/java/org/onap/cps/impl/CpsNotificationServiceImpl.java b/cps-service/src/main/java/org/onap/cps/impl/CpsNotificationServiceImpl.java
new file mode 100644 (file)
index 0000000..5030ad0
--- /dev/null
@@ -0,0 +1,140 @@
+/*
+ *  ============LICENSE_START=======================================================
+ *  Copyright (C) 2025 TechMahindra Ltd.
+ *  ================================================================================
+ *  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.impl;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.onap.cps.api.CpsAnchorService;
+import org.onap.cps.api.CpsNotificationService;
+import org.onap.cps.api.exceptions.DataNodeNotFoundException;
+import org.onap.cps.api.model.Anchor;
+import org.onap.cps.api.model.DataNode;
+import org.onap.cps.api.parameters.FetchDescendantsOption;
+import org.onap.cps.cpspath.parser.CpsPathUtil;
+import org.onap.cps.spi.CpsDataPersistenceService;
+import org.onap.cps.utils.ContentType;
+import org.onap.cps.utils.DataMapUtils;
+import org.onap.cps.utils.PrefixResolver;
+import org.onap.cps.utils.YangParser;
+import org.opendaylight.yangtools.yang.data.api.schema.ContainerNode;
+import org.springframework.stereotype.Service;
+
+@Service
+@Slf4j
+@RequiredArgsConstructor
+public class CpsNotificationServiceImpl implements CpsNotificationService {
+
+    private final CpsAnchorService cpsAnchorService;
+
+    private final CpsDataPersistenceService cpsDataPersistenceService;
+
+    private final YangParser yangParser;
+
+    private final PrefixResolver prefixResolver;
+
+    private static final String ADMIN_DATASPACE = "CPS-Admin";
+    private static final String ANCHOR_NAME = "cps-notification-subscriptions";
+    private static final String DATASPACE_SUBSCRIPTION_XPATH_FORMAT = "/dataspaces/dataspace[@name='%s']";
+    private static final String ANCHORS_SUBSCRIPTION_XPATH_FORMAT = "/dataspaces/dataspace[@name='%s']/anchors";
+    private static final String ANCHOR_SUBSCRIPTION_XPATH_FORMAT =
+            "/dataspaces/dataspace[@name='%s']/anchors/anchor[@name='%s']";
+
+    @Override
+    public void createNotificationSubscription(final String notificationSubscriptionAsJson, final String xpath) {
+
+        final Anchor anchor = cpsAnchorService.getAnchor(ADMIN_DATASPACE, ANCHOR_NAME);
+        final Collection<DataNode> dataNodes =
+                buildDataNodesWithParentNodeXpath(anchor, xpath, notificationSubscriptionAsJson, ContentType.JSON);
+        cpsDataPersistenceService.addListElements(ADMIN_DATASPACE, ANCHOR_NAME, xpath,
+                dataNodes);
+    }
+
+    @Override
+    public void deleteNotificationSubscription(final String xpath) {
+        cpsDataPersistenceService.deleteDataNode(ADMIN_DATASPACE, ANCHOR_NAME, xpath);
+    }
+
+    @Override
+    public List<Map<String, Object>> getNotificationSubscription(final String xpath) {
+        final Collection<DataNode> dataNodes =
+                cpsDataPersistenceService.getDataNodes(ADMIN_DATASPACE, ANCHOR_NAME, xpath,
+                FetchDescendantsOption.INCLUDE_ALL_DESCENDANTS);
+        final List<Map<String, Object>> dataMaps = new ArrayList<>(dataNodes.size());
+        final Anchor anchor = cpsAnchorService.getAnchor(ADMIN_DATASPACE, ANCHOR_NAME);
+        for (final DataNode dataNode: dataNodes) {
+            final String prefix = prefixResolver.getPrefix(anchor, dataNode.getXpath());
+            final Map<String, Object> dataMap = DataMapUtils.toDataMapWithIdentifier(dataNode, prefix);
+            dataMaps.add(dataMap);
+        }
+        return dataMaps;
+    }
+
+    @Override
+    public boolean isNotificationEnabled(final String dataspaceName, final String anchorName) {
+        return (isNotificationEnabledForAnchor(dataspaceName, anchorName)
+                || notificationEnabledForAllAnchors(dataspaceName));
+    }
+
+    private boolean isNotificationEnabledForAnchor(final String dataspaceName, final String anchorName) {
+        final String xpath = String.format(ANCHOR_SUBSCRIPTION_XPATH_FORMAT, dataspaceName, anchorName);
+        return isNotificationEnabledForXpath(xpath);
+    }
+
+    private boolean isNotificationEnabledForXpath(final String xpath) {
+        try {
+            cpsDataPersistenceService.getDataNodes(ADMIN_DATASPACE, ANCHOR_NAME, xpath,
+                FetchDescendantsOption.INCLUDE_ALL_DESCENDANTS);
+        } catch (final DataNodeNotFoundException e) {
+            return false;
+        }
+        return true;
+    }
+
+    private boolean notificationEnabledForAllAnchors(final String dataspaceName) {
+        final String dataspaceSubscriptionXpath = String.format(DATASPACE_SUBSCRIPTION_XPATH_FORMAT, dataspaceName);
+        return (isNotificationEnabledForXpath(dataspaceSubscriptionXpath)
+                && noIndividualAnchorEnabledInDataspace(dataspaceName));
+    }
+
+    private boolean noIndividualAnchorEnabledInDataspace(final String dataspaceName) {
+        final String xpathForAnchors = String.format(ANCHORS_SUBSCRIPTION_XPATH_FORMAT, dataspaceName);
+        return !isNotificationEnabledForXpath(xpathForAnchors);
+    }
+
+
+    private Collection<DataNode> buildDataNodesWithParentNodeXpath(final Anchor anchor, final String parentNodeXpath,
+                                                                   final String nodeData,
+                                                                   final ContentType contentType) {
+
+        final String normalizedParentNodeXpath = CpsPathUtil.getNormalizedXpath(parentNodeXpath);
+        final ContainerNode containerNode =
+                yangParser.parseData(contentType, nodeData, anchor, normalizedParentNodeXpath);
+        final Collection<DataNode> dataNodes = new DataNodeBuilder()
+                .withParentNodeXpath(normalizedParentNodeXpath)
+                .withContainerNode(containerNode)
+                .buildCollection();
+        return dataNodes;
+    }
+}
\ No newline at end of file
index 0b7d160..bf60f8d 100644 (file)
@@ -1,6 +1,6 @@
 /*
  *  ============LICENSE_START=======================================================
- *  Copyright (C) 2024 TechMahindra Ltd.
+ *  Copyright (C) 2024-2025 TechMahindra Ltd.
  *  ================================================================================
  *  Licensed under the Apache License, Version 2.0 (the "License");
  *  you may not use this file except in compliance with the License.
index 5dee8fc..6d9ff12 100644 (file)
@@ -1,6 +1,6 @@
 /*
  * ============LICENSE_START=======================================================
- * Copyright (C) 2024 TechMahindra Ltd.
+ * Copyright (C) 2024-2025 TechMahindra Ltd.
  * Copyright (C) 2024 Nordix Foundation.
  * ================================================================================
  * Licensed under the Apache License, Version 2.0 (the "License");
@@ -25,6 +25,7 @@ import static org.onap.cps.events.model.Data.Operation.CREATE
 import static org.onap.cps.events.model.Data.Operation.DELETE
 import static org.onap.cps.events.model.Data.Operation.UPDATE
 
+import org.onap.cps.api.CpsNotificationService
 import com.fasterxml.jackson.databind.ObjectMapper
 import io.cloudevents.CloudEvent
 import io.cloudevents.core.CloudEventUtils
@@ -41,8 +42,13 @@ import java.time.OffsetDateTime
 class CpsDataUpdateEventsServiceSpec extends Specification {
     def mockEventsPublisher = Mock(EventsPublisher)
     def objectMapper = new ObjectMapper();
+    def mockCpsNotificationService = Mock(CpsNotificationService)
 
-    def objectUnderTest = new CpsDataUpdateEventsService(mockEventsPublisher)
+    def objectUnderTest = new CpsDataUpdateEventsService(mockEventsPublisher, mockCpsNotificationService)
+
+    def setup() {
+        mockCpsNotificationService.isNotificationEnabled('dataspace01', 'anchor01') >> true
+    }
 
     def 'Create and Publish cps update event where events are #scenario'() {
         given: 'an anchor, operation and observed timestamp'
diff --git a/cps-service/src/test/groovy/org/onap/cps/impl/CpsNotificationServiceImplSpec.groovy b/cps-service/src/test/groovy/org/onap/cps/impl/CpsNotificationServiceImplSpec.groovy
new file mode 100644 (file)
index 0000000..0f56327
--- /dev/null
@@ -0,0 +1,166 @@
+/*
+ * ============LICENSE_START=======================================================
+ * Copyright (C) 2025 TechMahindra Ltd.
+ * ================================================================================
+ * 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.impl
+
+import com.fasterxml.jackson.databind.ObjectMapper
+import org.onap.cps.api.CpsAnchorService
+import org.onap.cps.api.exceptions.DataNodeNotFoundException
+import org.onap.cps.api.exceptions.DataValidationException
+import org.onap.cps.api.model.Anchor
+import org.onap.cps.api.parameters.FetchDescendantsOption;
+import org.onap.cps.spi.CpsDataPersistenceService
+import org.onap.cps.utils.JsonObjectMapper
+import org.onap.cps.utils.PrefixResolver
+import org.onap.cps.utils.YangParser
+import org.onap.cps.TestUtils
+import org.onap.cps.utils.YangParserHelper
+import org.onap.cps.yang.TimedYangTextSchemaSourceSetBuilder
+import org.onap.cps.yang.YangTextSchemaSourceSet
+import org.onap.cps.yang.YangTextSchemaSourceSetBuilder
+import org.springframework.test.context.ContextConfiguration
+
+import spock.lang.Specification
+
+@ContextConfiguration(classes = [ObjectMapper, JsonObjectMapper])
+class CpsNotificationServiceImplSpec extends Specification {
+
+    def dataspaceName = 'CPS-Admin'
+    def anchorName = 'cps-notification-subscriptions'
+    def schemaSetName = 'cps-notification-subscriptions'
+    def anchor = new Anchor(anchorName, dataspaceName, schemaSetName)
+
+    def mockCpsDataPersistenceService = Mock(CpsDataPersistenceService)
+    def mockCpsAnchorService = Mock(CpsAnchorService)
+    def mockYangTextSchemaSourceSetCache = Mock(YangTextSchemaSourceSetCache)
+    def mockTimedYangTextSchemaSourceSetBuilder = Mock(TimedYangTextSchemaSourceSetBuilder)
+    def yangParser = new YangParser(new YangParserHelper(), mockYangTextSchemaSourceSetCache, mockTimedYangTextSchemaSourceSetBuilder)
+    def  mockPrefixResolver = Mock(PrefixResolver)
+    def objectUnderTest = new CpsNotificationServiceImpl(mockCpsAnchorService, mockCpsDataPersistenceService, yangParser, mockPrefixResolver)
+
+    def 'add notification subscription for list of dataspaces'() {
+        given: 'details for notification subscription and subscription root node xpath'
+            def notificationSubscriptionAsjson = '{"dataspace":[{"name":"ds01"},{"name":"ds02"}]}'
+            def xpath = '/dataspaces'
+        and: 'schema set for given anchor and dataspace references notification subscription model'
+            setupSchemaSetMocks('cps-notification-subscriptions@2024-07-03.yang')
+        and: 'anchor is provided'
+            mockCpsAnchorService.getAnchor(dataspaceName, anchorName) >> anchor
+        when: 'create notification subscription is called'
+            objectUnderTest.createNotificationSubscription(notificationSubscriptionAsjson, xpath)
+        then: 'the persistence service is called once with the correct parameters'
+            1 * mockCpsDataPersistenceService.addListElements('CPS-Admin', 'cps-notification-subscriptions', xpath, { dataNodeCollection ->
+                {
+                    assert dataNodeCollection.size() == 2
+                    assert dataNodeCollection.collect { it.getXpath() }
+                            .containsAll(['/dataspaces/dataspace[@name=\'ds01\']', '/dataspaces/dataspace[@name=\'ds02\']'])
+                }
+            })
+    }
+
+    def 'add notification subscription fails with exception'() {
+        given: 'details for notification subscription'
+            def jsonData = '{"dataspace":[{"name":"ds01"},{"name":"ds02"}]}'
+        and: 'schema set for given anchor and dataspace references invalid data model'
+            setupSchemaSetMocks('test-tree.yang')
+        and: 'anchor is provided'
+            mockCpsAnchorService.getAnchor(dataspaceName, anchorName) >> anchor
+        when: 'create notification subscription is called'
+            objectUnderTest.createNotificationSubscription(jsonData, '/somepath')
+        then: 'data validation exception is thrown '
+            thrown(DataValidationException)
+    }
+
+    def 'delete notification subscription for given xpath'() {
+        given: 'details for notification subscription'
+            def xpath = '/some/path'
+        when: 'delete notification subscription is called'
+            objectUnderTest.deleteNotificationSubscription(xpath)
+        then: 'the persistence service is called once with the correct parameters'
+            1 * mockCpsDataPersistenceService.deleteDataNode(dataspaceName, anchorName, xpath)
+    }
+
+    def 'get notification subscription for given xpath'() {
+        given: 'details for notification subscription'
+            def xpath = '/some/path'
+        and: 'persistence service returns data nodes for subscribed data'
+            mockCpsDataPersistenceService.getDataNodes(dataspaceName, anchorName,
+                    xpath, FetchDescendantsOption.INCLUDE_ALL_DESCENDANTS) >>
+                    [new DataNodeBuilder().withXpath('/some/path').withLeaves([leaf: 'dataspace', leafList: ['ds01', 'ds02']]).build()]
+        when: 'delete notification subscription is called'
+            def result =  objectUnderTest.getNotificationSubscription(xpath)
+        then: 'the result is a json representation of the data node(s) returned by the data persistence service'
+            assert result.get(0).toString() == '{path={leaf=dataspace, leafList=[ds01, ds02]}}'
+    }
+
+    def 'is notification enabled for given anchor'() {
+        given: 'data nodes available for given anchor'
+            mockCpsDataPersistenceService.getDataNodes(dataspaceName, anchorName, "/dataspaces", FetchDescendantsOption.INCLUDE_ALL_DESCENDANTS) >>
+                    [new DataNodeBuilder().withXpath('/xpath-1').build()]
+        when: 'is notification enabled is called'
+            boolean isNotificationEnabled = objectUnderTest.isNotificationEnabled(dataspaceName, anchorName)
+        then: 'the notification is enabled'
+            assert isNotificationEnabled
+    }
+
+    def 'is notification disabled for given anchor'() {
+        given: 'data nodes not available for given anchor'
+            mockCpsDataPersistenceService.getDataNodes(dataspaceName, anchorName, "/dataspaces/dataspace[@name='ds01']/anchors/anchor[@name='anchor-01']", FetchDescendantsOption.INCLUDE_ALL_DESCENDANTS) >>
+                    {  throw new DataNodeNotFoundException(dataspaceName, anchorName) }
+        when: 'is notification enabled is called'
+            boolean isNotificationEnabled = objectUnderTest.isNotificationEnabled('ds01', 'anchor-01')
+        then: 'the notification is disabled'
+            assert !isNotificationEnabled
+    }
+
+    def 'is notification enabled for all anchors in a dataspace'() {
+        given: 'data nodes available for given dataspace'
+            mockCpsDataPersistenceService.getDataNodes(dataspaceName, anchorName, "/dataspaces/dataspace[@name='ds01']", FetchDescendantsOption.INCLUDE_ALL_DESCENDANTS) >>
+                    [new DataNodeBuilder().withXpath('/xpath-1').build()]
+        and: 'data nodes not available for any specific anchor'
+            mockCpsDataPersistenceService.getDataNodes(dataspaceName, anchorName, "/dataspaces/dataspace[@name='ds01']/anchors", FetchDescendantsOption.INCLUDE_ALL_DESCENDANTS) >>
+                    {  throw new DataNodeNotFoundException(dataspaceName, anchorName) }
+        when: 'is notification enabled is called'
+            boolean isNotificationEnabled = objectUnderTest.notificationEnabledForAllAnchors('ds01')
+        then: 'the notification is enabled'
+            assert isNotificationEnabled
+    }
+
+    def 'is notification disabled for all anchors in a dataspace'() {
+        given: 'data nodes available for given dataspace'
+            mockCpsDataPersistenceService.getDataNodes(dataspaceName, anchorName, "/dataspaces/dataspace[@name='ds01']", FetchDescendantsOption.INCLUDE_ALL_DESCENDANTS) >>
+                [new DataNodeBuilder().withXpath('/xpath-1').build()]
+        and: 'data nodes also available for any specific anchor'
+            mockCpsDataPersistenceService.getDataNodes(dataspaceName, anchorName, "/dataspaces/dataspace[@name='ds01']/anchors", FetchDescendantsOption.INCLUDE_ALL_DESCENDANTS) >>
+                    [new DataNodeBuilder().withXpath('/xpath-1').build()]
+        when: 'is notification enabled is called'
+            boolean isNotificationEnabled = objectUnderTest.notificationEnabledForAllAnchors('ds01')
+        then: 'the notification is disabled'
+            assert !isNotificationEnabled
+    }
+
+    def setupSchemaSetMocks(String... yangResources) {
+        def mockYangTextSchemaSourceSet = Mock(YangTextSchemaSourceSet)
+        mockYangTextSchemaSourceSetCache.get(dataspaceName, schemaSetName) >> mockYangTextSchemaSourceSet
+        def yangResourceNameToContent = TestUtils.getYangResourcesAsMap(yangResources)
+        def schemaContext = YangTextSchemaSourceSetBuilder.of(yangResourceNameToContent).getSchemaContext()
+        mockYangTextSchemaSourceSet.getSchemaContext() >> schemaContext
+    }
+}
index 0d515f9..1e2dc54 100644 (file)
@@ -1,6 +1,6 @@
 /*
  *  ============LICENSE_START=======================================================
- *  Copyright (C) 2024 TechMahindra Ltd.
+ *  Copyright (C) 2024-2025 TechMahindra Ltd.
  *  ================================================================================
  *  Licensed under the Apache License, Version 2.0 (the "License");
  *  you may not use this file except in compliance with the License.
@@ -78,10 +78,4 @@ class CpsNotificationSubscriptionModelLoaderSpec extends Specification {
         and: 'the data service to create a top level datanode is called once'
             1 * mockCpsDataService.saveData(CPS_DATASPACE_NAME, ANCHOR_NAME, '{"dataspaces":{}}', _)
     }
-
-    private void assertLogContains(String message) {
-        def logs = loggingListAppender.list.toString()
-        assert logs.contains(message)
-    }
-
 }
diff --git a/cps-service/src/test/resources/cps-notification-subscriptions@2024-07-03.yang b/cps-service/src/test/resources/cps-notification-subscriptions@2024-07-03.yang
new file mode 100644 (file)
index 0000000..1cab792
--- /dev/null
@@ -0,0 +1,48 @@
+module cps-notification-subscriptions {
+    yang-version 1.1;
+    namespace "org:onap:cps";
+
+    prefix cps-notification-subscriptions;
+
+    revision "2024-08-05" {
+        description
+            "First release of cps notification subscriptions model";
+    }
+    container dataspaces {
+
+        list dataspace {
+            key "name";
+
+            leaf name {
+                type string;
+            }
+
+            container anchors {
+
+                list anchor {
+                    key "name";
+
+                    leaf name {
+                        type string;
+                    }
+
+                    container xpaths {
+
+                        list xpath {
+                            key "path";
+                            leaf path {
+                                type string;
+                            }
+                        }
+                    }
+                }
+            }
+            leaf-list subscriptionIds {
+                type string;
+            }
+            leaf topic {
+                type string;
+            }
+        }
+    }
+}
\ No newline at end of file
index 330c2ca..1545487 100644 (file)
@@ -332,7 +332,7 @@ paths:
       - cps-admin
   /{apiVersion}/admin/dataspaces/{dataspace-name}/actions/clean:
     post:
-      description: Clean the dataspace (remove orphaned modules)
+      description: Clean the dataspace (remove orphaned schema sets and modules)
       operationId: cleanDataspace
       parameters:
       - description: apiVersion
@@ -2562,6 +2562,208 @@ paths:
       tags:
       - cps-query
       x-codegen-request-body-name: xpath
+  /v2/notification-subscription:
+    delete:
+      description: Delete cps notification subscription
+      operationId: deleteNotificationSubscription
+      parameters:
+      - description: "For more details on xpath, please refer https://docs.onap.org/projects/onap-cps/en/latest/xpath.html"
+        examples:
+          subscription by dataspace xpath:
+            value: "/dataspaces/dataspace[@name='dataspace01']"
+          subscription by anchor xpath:
+            value: "/dataspaces/dataspace[@name='dataspace01']/anchors/anchor[@name='anchor01']"
+        in: query
+        name: xpath
+        required: true
+        schema:
+          default: /dataspaces
+          type: string
+      responses:
+        "204":
+          content: {}
+          description: No Content
+        "400":
+          content:
+            application/json:
+              example:
+                status: 400
+                message: Bad Request
+                details: The provided request is not valid
+              schema:
+                $ref: '#/components/schemas/ErrorMessage'
+          description: Bad Request
+        "403":
+          content:
+            application/json:
+              example:
+                status: 403
+                message: Request Forbidden
+                details: This request is forbidden
+              schema:
+                $ref: '#/components/schemas/ErrorMessage'
+          description: Forbidden
+        "409":
+          content:
+            application/json:
+              example:
+                status: 409
+                message: Conflicting request
+                details: The request cannot be processed as the resource is in use.
+              schema:
+                $ref: '#/components/schemas/ErrorMessage'
+          description: Conflict
+        "500":
+          content:
+            application/json:
+              example:
+                status: 500
+                message: Internal Server Error
+                details: Internal Server Error occurred
+              schema:
+                $ref: '#/components/schemas/ErrorMessage'
+          description: Internal Server Error
+      summary: Delete cps notification subscription
+      tags:
+      - cps-admin
+    get:
+      description: Get cps notification subscription
+      operationId: getNotificationSubscription
+      parameters:
+      - description: "For more details on xpath, please refer https://docs.onap.org/projects/onap-cps/en/latest/xpath.html"
+        examples:
+          subscription by dataspace xpath:
+            value: "/dataspaces/dataspace[@name='dataspace01']"
+          subscription by anchor xpath:
+            value: "/dataspaces/dataspace[@name='dataspace01']/anchors/anchor[@name='anchor01']"
+        in: query
+        name: xpath
+        required: true
+        schema:
+          default: /dataspaces
+          type: string
+      responses:
+        "200":
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/NotificationSubscriptionsDataSample'
+          description: OK
+        "400":
+          content:
+            application/json:
+              example:
+                status: 400
+                message: Bad Request
+                details: The provided request is not valid
+              schema:
+                $ref: '#/components/schemas/ErrorMessage'
+          description: Bad Request
+        "403":
+          content:
+            application/json:
+              example:
+                status: 403
+                message: Request Forbidden
+                details: This request is forbidden
+              schema:
+                $ref: '#/components/schemas/ErrorMessage'
+          description: Forbidden
+        "409":
+          content:
+            application/json:
+              example:
+                status: 409
+                message: Conflicting request
+                details: The request cannot be processed as the resource is in use.
+              schema:
+                $ref: '#/components/schemas/ErrorMessage'
+          description: Conflict
+        "500":
+          content:
+            application/json:
+              example:
+                status: 500
+                message: Internal Server Error
+                details: Internal Server Error occurred
+              schema:
+                $ref: '#/components/schemas/ErrorMessage'
+          description: Internal Server Error
+      summary: Get cps notification subscription
+      tags:
+      - cps-admin
+    post:
+      description: Create cps notification subscription
+      operationId: createNotificationSubscription
+      parameters:
+      - description: "For more details on xpath, please refer https://docs.onap.org/projects/onap-cps/en/latest/xpath.html"
+        examples:
+          subscription by dataspace xpath:
+            value: "/dataspaces/dataspace[@name='dataspace01']"
+          subscription by anchor xpath:
+            value: "/dataspaces/dataspace[@name='dataspace01']/anchors/anchor[@name='anchor01']"
+        in: query
+        name: xpath
+        required: true
+        schema:
+          default: /dataspaces
+          type: string
+      requestBody:
+        content:
+          application/json:
+            examples:
+              dataSample:
+                $ref: '#/components/examples/NotificationSubscriptionsDataSample'
+                value: null
+            schema:
+              type: object
+        required: true
+      responses:
+        "201":
+          description: Created without response body
+        "400":
+          content:
+            application/json:
+              example:
+                status: 400
+                message: Bad Request
+                details: The provided request is not valid
+              schema:
+                $ref: '#/components/schemas/ErrorMessage'
+          description: Bad Request
+        "403":
+          content:
+            application/json:
+              example:
+                status: 403
+                message: Request Forbidden
+                details: This request is forbidden
+              schema:
+                $ref: '#/components/schemas/ErrorMessage'
+          description: Forbidden
+        "409":
+          content:
+            application/json:
+              example:
+                status: 409
+                message: Conflicting request
+                details: The request cannot be processed as the resource is in use.
+              schema:
+                $ref: '#/components/schemas/ErrorMessage'
+          description: Conflict
+        "500":
+          content:
+            application/json:
+              example:
+                status: 500
+                message: Internal Server Error
+                details: Internal Server Error occurred
+              schema:
+                $ref: '#/components/schemas/ErrorMessage'
+          description: Internal Server Error
+      summary: Create cps notification subscription
+      tags:
+      - cps-admin
 components:
   examples:
     dataSample:
@@ -2615,6 +2817,12 @@ components:
               name: SciFi
             - code: 2
               name: kids
+    NotificationSubscriptionsDataSample:
+      value:
+        cps-notification-subscriptions:dataspaces:
+          dataspace:
+          - name: dataspace01
+          - name: dataspace02
   parameters:
     dataspaceNameInQuery:
       description: dataspace-name
@@ -2795,6 +3003,19 @@ components:
       schema:
         example: 10
         type: integer
+    notificationSubscriptionXpathInQuery:
+      description: "For more details on xpath, please refer https://docs.onap.org/projects/onap-cps/en/latest/xpath.html"
+      examples:
+        subscription by dataspace xpath:
+          value: "/dataspaces/dataspace[@name='dataspace01']"
+        subscription by anchor xpath:
+          value: "/dataspaces/dataspace[@name='dataspace01']/anchors/anchor[@name='anchor01']"
+      in: query
+      name: xpath
+      required: true
+      schema:
+        default: /dataspaces
+        type: string
   responses:
     Created:
       content:
@@ -2956,6 +3177,7 @@ components:
           type: string
       title: Module reference object
       type: object
+    NotificationSubscriptionsDataSample: {}
     getDeltaByDataspaceAnchorAndPayload_request:
       properties:
         json: