From 9d812203b3c0550995be8d60e7b11761bdbac04d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Pecka?= <tomas.pecka@cesnet.cz>
Date: Tue, 1 Apr 2025 21:22:04 +0200
Subject: [PATCH 13/20] add subtree filtering to notifications subscription
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires

Change-Id: Ie547b5f478cc8e3b09ea2f324b62854576787e1b
Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
 CMakeLists.txt                  |   2 +-
 include/sysrepo-cpp/Session.hpp |   6 +-
 src/Session.cpp                 |  58 +++++++++--
 tests/subscriptions-dynamic.cpp | 173 ++++++++++++++++++++++++++++++--
 4 files changed, 213 insertions(+), 26 deletions(-)

diff --git a/CMakeLists.txt b/CMakeLists.txt
index 01ec967..47472d2 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -82,7 +82,7 @@ if(BUILD_TESTING)
         --install ${CMAKE_CURRENT_SOURCE_DIR}/tests/yang/ietf-interfaces@2018-02-20.yang
         --install ${CMAKE_CURRENT_SOURCE_DIR}/tests/yang/ietf-ip@2018-02-22.yang
         --install ${CMAKE_CURRENT_SOURCE_DIR}/tests/yang/ietf-network-instance@2019-01-21.yang
-        --install ${CMAKE_CURRENT_SOURCE_DIR}/tests/yang/ietf-subscribed-notifications@2019-09-09.yang -e replay
+        --install ${CMAKE_CURRENT_SOURCE_DIR}/tests/yang/ietf-subscribed-notifications@2019-09-09.yang -e replay -e subtree
         --install ${CMAKE_CURRENT_SOURCE_DIR}/tests/yang/ietf-yang-push@2019-09-09.yang -e on-change
         )
 
diff --git a/include/sysrepo-cpp/Session.hpp b/include/sysrepo-cpp/Session.hpp
index 255f861..0781890 100644
--- a/include/sysrepo-cpp/Session.hpp
+++ b/include/sysrepo-cpp/Session.hpp
@@ -137,17 +137,17 @@ public:
             const std::optional<FDHandling>& callbacks = std::nullopt);
 
     [[nodiscard]] DynamicSubscription yangPushPeriodic(
-        const std::optional<std::string>& xpathFilter,
+        const std::optional<std::variant<std::string, libyang::DataNodeAny>>& filter,
         std::chrono::milliseconds periodTime,
         const std::optional<NotificationTimeStamp>& anchorTime = std::nullopt,
         const std::optional<NotificationTimeStamp>& stopTime = std::nullopt);
     [[nodiscard]] DynamicSubscription yangPushOnChange(
-        const std::optional<std::string>& xpathFilter,
+        const std::optional<std::variant<std::string, libyang::DataNodeAny>>& filter,
         const std::optional<std::chrono::milliseconds>& dampeningPeriod = std::nullopt,
         SyncOnStart syncOnStart = SyncOnStart::No,
         const std::optional<NotificationTimeStamp>& stopTime = std::nullopt);
     [[nodiscard]] DynamicSubscription subscribeNotifications(
-        const std::optional<std::string>& xpathFilter,
+        const std::optional<std::variant<std::string, libyang::DataNodeAny>>& filter,
         const std::optional<std::string>& stream = std::nullopt,
         const std::optional<NotificationTimeStamp>& stopTime = std::nullopt,
         const std::optional<NotificationTimeStamp>& startTime = std::nullopt);
diff --git a/src/Session.cpp b/src/Session.cpp
index 2c27c92..1d65d70 100644
--- a/src/Session.cpp
+++ b/src/Session.cpp
@@ -42,6 +42,37 @@ libyang::DataNode wrapSrData(std::shared_ptr<sr_session_ctx_s> sess, sr_data_t*
         sr_release_data(data);
     }));
 }
+
+std::optional<std::string> constructXPathFilter(const std::optional<std::variant<std::string, libyang::DataNodeAny>>& filter)
+{
+    if (!filter) {
+        return std::nullopt;
+    }
+
+    if (std::holds_alternative<std::string>(*filter)) {
+        return std::get<std::string>(*filter);
+    }
+
+    auto node = std::get<libyang::DataNodeAny>(*filter);
+    auto value = node.releaseValue();
+
+    if (!value) {
+        return "/"; // select nothing, RFC 6241, 6.4.2
+    }
+
+    if (std::holds_alternative<libyang::DataNode>(*value)) {
+        char* str;
+
+        auto filterTree = std::get<libyang::DataNode>(*value);
+        auto res = srsn_filter_subtree2xpath(libyang::getRawNode(filterTree), nullptr, &str);
+        std::unique_ptr<char, decltype([](auto* p) constexpr { std::free(p); })> strDeleter(str); // pass ownership of c-string to the deleter
+
+        throwIfError(res, "Unable to convert subtree filter to xpath");
+        return str;
+    }
+
+    throw Error("Subtree filter anydata node must contain (possibly empty) libyang tree");
+}
 }
 
 /**
@@ -526,9 +557,9 @@ Subscription Session::onNotification(
 /**
  * Subscribe for receiving notifications according to 'ietf-yang-push' YANG periodic subscriptions.
  *
- * Wraps `srsn_yang_push_periodic`.
+ * Wraps `srsn_subscribe` and `srsn_filter_subtree2xpath` for subtree filters.
  *
- * @param xpathFilter Optional XPath that filters received notification.
+ * @param filter Optional filter for received notification, xpath filter for string type, subtree filter for libyang::DataNodeAny
  * @param periodTime Notification period.
  * @param anchorTime Optional anchor time for the period. Anchor time acts as a reference point for the period.
  * @param stopTime Optional stop time ending the notification subscription.
@@ -536,7 +567,7 @@ Subscription Session::onNotification(
  * @return A YangPushSubscription handle.
  */
 DynamicSubscription Session::yangPushPeriodic(
-    const std::optional<std::string>& xpathFilter,
+    const std::optional<std::variant<std::string, libyang::DataNodeAny>>& filter,
     std::chrono::milliseconds periodTime,
     const std::optional<NotificationTimeStamp>& anchorTime,
     const std::optional<NotificationTimeStamp>& stopTime)
@@ -545,6 +576,8 @@ DynamicSubscription Session::yangPushPeriodic(
     uint32_t subId;
     auto stopSpec = stopTime ? std::optional{toTimespec(*stopTime)} : std::nullopt;
     auto anchorSpec = anchorTime ? std::optional{toTimespec(*anchorTime)} : std::nullopt;
+    auto xpathFilter = constructXPathFilter(filter);
+
     auto res = srsn_yang_push_periodic(m_sess.get(),
                                        toDatastore(activeDatastore()),
                                        xpathFilter ? xpathFilter->c_str() : nullptr,
@@ -561,9 +594,9 @@ DynamicSubscription Session::yangPushPeriodic(
 /**
  * Subscribe for receiving notifications according to 'ietf-yang-push' YANG on-change subscriptions.
  *
- * Wraps `srsn_yang_push_on_change`.
+ * Wraps `srsn_subscribe` and `srsn_filter_subtree2xpath` for subtree filters.
  *
- * @param xpathFilter Optional XPath that filters received notification.
+ * @param filter Optional filter for received notification, xpath filter for string type, subtree filter for libyang::DataNodeAny
  * @param dampeningPeriod Optional dampening period.
  * @param syncOnStart Whether to start with a notification of the current state.
  * @param stopTime Optional stop time ending the notification subscription.
@@ -571,7 +604,7 @@ DynamicSubscription Session::yangPushPeriodic(
  * @return A YangPushSubscription handle.
  */
 DynamicSubscription Session::yangPushOnChange(
-    const std::optional<std::string>& xpathFilter,
+    const std::optional<std::variant<std::string, libyang::DataNodeAny>>& filter,
     const std::optional<std::chrono::milliseconds>& dampeningPeriod,
     SyncOnStart syncOnStart,
     const std::optional<NotificationTimeStamp>& stopTime)
@@ -579,6 +612,8 @@ DynamicSubscription Session::yangPushOnChange(
     int fd;
     uint32_t subId;
     auto stopSpec = stopTime ? std::optional{toTimespec(*stopTime)} : std::nullopt;
+    auto xpathFilter = constructXPathFilter(filter);
+
     auto res = srsn_yang_push_on_change(m_sess.get(),
                                         toDatastore(activeDatastore()),
                                         xpathFilter ? xpathFilter->c_str() : nullptr,
@@ -596,11 +631,11 @@ DynamicSubscription Session::yangPushOnChange(
 }
 
 /**
- * Subscribe for receiving notifications according to 'ietf-subscribed-notifications'
+ * Subscribe for receiving notifications according to 'ietf-subscribed-notifications'.
  *
- * Wraps `srsn_subscribe.
+ * Wraps `srsn_subscribe` and `srsn_filter_subtree2xpath` for subtree filters.
  *
- * @param xpathFilter Optional XPath that filters received notification.
+ * @param filter Optional filter for received notification, xpath filter for string type, subtree filter for libyang::DataNodeAny
  * @param stream Optional stream to subscribe to.
  * @param stopTime Optional stop time ending the subscription.
  * @param startTime Optional start time of the subscription, used for replaying stored notifications.
@@ -608,7 +643,7 @@ DynamicSubscription Session::yangPushOnChange(
  * @return A YangPushSubscription handle.
  */
 DynamicSubscription Session::subscribeNotifications(
-    const std::optional<std::string>& xpathFilter,
+    const std::optional<std::variant<std::string, libyang::DataNodeAny>>& filter,
     const std::optional<std::string>& stream,
     const std::optional<NotificationTimeStamp>& stopTime,
     const std::optional<NotificationTimeStamp>& startTime)
@@ -618,10 +653,11 @@ DynamicSubscription Session::subscribeNotifications(
     auto stopSpec = stopTime ? std::optional{toTimespec(*stopTime)} : std::nullopt;
     auto startSpec = startTime ? std::optional{toTimespec(*startTime)} : std::nullopt;
     struct timespec replayStartSpec;
+    auto xpathFilter = constructXPathFilter(filter);
 
     auto res = srsn_subscribe(m_sess.get(),
                               stream ? stream->c_str() : nullptr,
-                              xpathFilter ? xpathFilter->c_str() : nullptr,
+                              xpathFilter ? xpathFilter->data() : nullptr,
                               stopSpec ? &stopSpec.value() : nullptr,
                               startSpec ? &startSpec.value() : nullptr,
                               false,
diff --git a/tests/subscriptions-dynamic.cpp b/tests/subscriptions-dynamic.cpp
index a44bfba..84a0880 100644
--- a/tests/subscriptions-dynamic.cpp
+++ b/tests/subscriptions-dynamic.cpp
@@ -28,6 +28,9 @@
 #define REQUIRE_NOTIFICATION(SUBSCRIPTION, NOTIFICATION) \
     TROMPELOEIL_REQUIRE_CALL(rec, recordNotification(NOTIFICATION)).IN_SEQUENCE(seq);
 
+#define REQUIRE_NAMED_NOTIFICATION(SUBSCRIPTION, NOTIFICATION) \
+    expectations.emplace_back(TROMPELOEIL_NAMED_REQUIRE_CALL(rec, recordNotification(NOTIFICATION)).IN_SEQUENCE(seq));
+
 #define READ_NOTIFICATION(SUBSCRIPTION)                                \
     REQUIRE(pipeStatus((SUBSCRIPTION).fd()) == PipeStatus::DataReady); \
     (SUBSCRIPTION).processEvent(cbNotif);
@@ -276,6 +279,89 @@ TEST_CASE("Dynamic subscriptions")
             const auto excMessage = "Couldn't terminate yang-push subscription with id " + std::to_string(sub->subscriptionId()) + ": SR_ERR_NOT_FOUND";
             REQUIRE_THROWS_WITH_AS(sub->terminate(), excMessage.c_str(), sysrepo::ErrorWithCode);
         }
+
+        DOCTEST_SUBCASE("Filtering")
+        {
+            std::optional<sysrepo::DynamicSubscription> sub;
+            std::vector<std::unique_ptr<trompeloeil::expectation>> expectations;
+
+            DOCTEST_SUBCASE("xpath filter")
+            {
+                sub = sess.subscribeNotifications("/test_module:ping");
+
+                REQUIRE_NAMED_NOTIFICATION(sub, notifications[0]);
+            }
+
+            DOCTEST_SUBCASE("subtree filter")
+            {
+                libyang::CreatedNodes createdNodes;
+
+                DOCTEST_SUBCASE("filter a node")
+                {
+                    DOCTEST_SUBCASE("XML")
+                    {
+                        createdNodes = sess.getContext().newPath2(
+                            "/ietf-subscribed-notifications:establish-subscription/stream-subtree-filter",
+                            libyang::XML{"<ping xmlns='urn:ietf:params:xml:ns:yang:test_module' />"});
+                    }
+
+                    DOCTEST_SUBCASE("JSON")
+                    {
+                        createdNodes = sess.getContext().newPath2(
+                            "/ietf-subscribed-notifications:establish-subscription/stream-subtree-filter",
+                            libyang::JSON{R"({"test_module:ping": {}})"});
+                    }
+
+                    REQUIRE_NAMED_NOTIFICATION(sub, notifications[0]);
+                }
+
+                DOCTEST_SUBCASE("filter more top level nodes")
+                {
+                    DOCTEST_SUBCASE("XML")
+                    {
+                        createdNodes = sess.getContext().newPath2(
+                            "/ietf-subscribed-notifications:establish-subscription/stream-subtree-filter",
+                            libyang::XML{"<ping xmlns='urn:ietf:params:xml:ns:yang:test_module' />"
+                                         "<silent-ping xmlns='urn:ietf:params:xml:ns:yang:test_module' />"});
+                    }
+
+                    DOCTEST_SUBCASE("JSON")
+                    {
+                        createdNodes = sess.getContext().newPath2(
+                            "/ietf-subscribed-notifications:establish-subscription/stream-subtree-filter",
+                            libyang::JSON{R"({
+                                "test_module:ping": {},
+                                "test_module:silent-ping": {}
+                            })"});
+                    }
+
+                    REQUIRE_NAMED_NOTIFICATION(sub, notifications[0]);
+                    REQUIRE_NAMED_NOTIFICATION(sub, notifications[1]);
+                }
+
+                DOCTEST_SUBCASE("empty filter selects nothing")
+                {
+                    createdNodes = sess.getContext().newPath2(
+                        "/ietf-subscribed-notifications:establish-subscription/stream-subtree-filter",
+                        std::nullopt);
+                }
+
+                sub = sess.subscribeNotifications(createdNodes.createdNode->asAny());
+            }
+
+            CLIENT_SEND_NOTIFICATION(notifications[0]);
+            CLIENT_SEND_NOTIFICATION(notifications[1]);
+
+            // read as many notifications as we expect
+            for (size_t i = 0; i < expectations.size(); ++i) {
+                READ_NOTIFICATION_BLOCKING(*sub);
+            }
+
+            sub->terminate();
+
+            // ensure no more notifications were sent
+            REQUIRE_PIPE_HANGUP(*sub);
+        }
     }
 
     DOCTEST_SUBCASE("YANG Push on change")
@@ -285,9 +371,73 @@ TEST_CASE("Dynamic subscriptions")
          * between writing to sysrepo and reading the notifications.
          */
 
-        auto sub = sess.yangPushOnChange(std::nullopt, std::nullopt, sysrepo::SyncOnStart::Yes);
+        DOCTEST_SUBCASE("Filters")
+        {
+            std::optional<sysrepo::DynamicSubscription> sub;
+
+            DOCTEST_SUBCASE("XPath filter")
+            {
+                sub = sess.yangPushOnChange("/test_module:leafInt32 | /test_module:popelnice/content/trash[name='asd']");
+            }
+
+            DOCTEST_SUBCASE("Subtree filter")
+            {
+                auto createdNodes = sess.getContext().newPath2(
+                    "/ietf-subscribed-notifications:establish-subscription/ietf-yang-push:datastore-subtree-filter",
+                    libyang::XML{"<leafInt32 xmlns='http://example.com/' />"
+                                 "<popelnice xmlns='http://example.com/'><content><trash><name>asd</name></trash></content></popelnice>"});
+                sub = sess.yangPushOnChange(createdNodes.createdNode->asAny());
+            }
+
+            client.setItem("/test_module:leafInt32", "42");
+            client.setItem("/test_module:popelnice/s", "asd");
+            client.setItem("/test_module:popelnice/content/trash[name='asd']", std::nullopt);
+            client.applyChanges();
 
-        REQUIRE_YANG_PUSH_UPDATE(sub, R"({
+            client.deleteItem("/test_module:popelnice/s");
+            client.applyChanges();
+
+            REQUIRE_YANG_PUSH_UPDATE(*sub, R"({
+  "ietf-yang-push:push-change-update": {
+    "datastore-changes": {
+      "yang-patch": {
+        "patch-id": "patch-1",
+        "edit": [
+          {
+            "edit-id": "edit-1",
+            "operation": "create",
+            "target": "/test_module:leafInt32",
+            "value": {
+              "test_module:leafInt32": 42
+            }
+          },
+          {
+            "edit-id": "edit-2",
+            "operation": "create",
+            "target": "/test_module:popelnice/content/trash[name='asd']",
+            "value": {
+              "test_module:trash": {
+                "name": "asd"
+              }
+            }
+          }
+        ]
+      }
+    }
+  }
+}
+)");
+            READ_YANG_PUSH_UPDATE(*sub);
+
+            sub->terminate();
+            REQUIRE_PIPE_HANGUP(*sub);
+        }
+
+        DOCTEST_SUBCASE("Sync on start")
+        {
+            auto sub = sess.yangPushOnChange(std::nullopt, std::nullopt, sysrepo::SyncOnStart::Yes);
+
+            REQUIRE_YANG_PUSH_UPDATE(sub, R"({
   "ietf-yang-push:push-update": {
     "datastore-contents": {
       "test_module:values": [
@@ -298,14 +448,14 @@ TEST_CASE("Dynamic subscriptions")
   }
 }
 )");
-        READ_YANG_PUSH_UPDATE(sub);
+            READ_YANG_PUSH_UPDATE(sub);
 
-        client.setItem("/test_module:leafInt32", "123");
-        client.setItem("/test_module:values[.='5']", std::nullopt);
-        client.deleteItem("/test_module:values[.='3']");
-        client.applyChanges();
+            client.setItem("/test_module:leafInt32", "123");
+            client.setItem("/test_module:values[.='5']", std::nullopt);
+            client.deleteItem("/test_module:values[.='3']");
+            client.applyChanges();
 
-        REQUIRE_YANG_PUSH_UPDATE(sub, R"({
+            REQUIRE_YANG_PUSH_UPDATE(sub, R"({
   "ietf-yang-push:push-change-update": {
     "datastore-changes": {
       "yang-patch": {
@@ -342,10 +492,11 @@ TEST_CASE("Dynamic subscriptions")
   }
 }
 )");
-        READ_YANG_PUSH_UPDATE(sub);
+            READ_YANG_PUSH_UPDATE(sub);
 
-        sub.terminate();
-        REQUIRE_PIPE_HANGUP(sub);
+            sub.terminate();
+            REQUIRE_PIPE_HANGUP(sub);
+        }
     }
 
     DOCTEST_SUBCASE("YANG Push periodic")
-- 
2.43.0

