DatastoreAccess: add support for generic user-defined RPCs

I was lazy and in the NETCONF backend, the fillMap patch does not check
for actual string prefix match, it just optimistically trims the RPC
prefix. I think this is safe (definitely in the "won't crash" department
due to use of std::string::substr(), but also in the "won't produce
garbage" context because libyang is expected to validate everything).

In the sysrepo backend, I had to introduce some duplication into that
visitor which converts from our data types to sysrepo. It tirns out that
sysrepo::Session::set_item requires a separate S_Val, whereas in context
of handling an RPC's output, we have a Vals_Holder which, after
reallocation, becomes a Vals instance, and that one does not support
replacing the individual Val instances by "something" -- one has to call
a Val::set, and these methods are different from Val::Val constructors.

I'm explicitly testing for lists and containers because the
documentation looked a bit scary -- I understood it in a way which make
me spend extra effort to make sure that all that has to be created gets
created.

Change-Id: I717af71d69b209c444e1c5fe6d8ec2c2fcbdde8b
diff --git a/src/datastore_access.hpp b/src/datastore_access.hpp
index 9e5c317..ae0d12a 100644
--- a/src/datastore_access.hpp
+++ b/src/datastore_access.hpp
@@ -46,6 +46,7 @@
     virtual void deletePresenceContainer(const std::string& path) = 0;
     virtual void createListInstance(const std::string& path) = 0;
     virtual void deleteListInstance(const std::string& path) = 0;
+    virtual Tree executeRpc(const std::string& path, const Tree& input) = 0;
 
     virtual std::shared_ptr<Schema> schema() = 0;
 
diff --git a/src/netconf_access.cpp b/src/netconf_access.cpp
index fc35be6..7387231 100644
--- a/src/netconf_access.cpp
+++ b/src/netconf_access.cpp
@@ -18,8 +18,12 @@
 // This is very similar to the fillMap lambda in SysrepoAccess, however,
 // Sysrepo returns a weird array-like structure, while libnetconf
 // returns libyang::Data_Node
-void fillMap(DatastoreAccess::Tree& res, const std::vector<std::shared_ptr<libyang::Data_Node>> items)
+void fillMap(DatastoreAccess::Tree& res, const std::vector<std::shared_ptr<libyang::Data_Node>> items, std::optional<std::string> ignoredXPathPrefix = std::nullopt)
 {
+    auto stripXPathPrefix = [&ignoredXPathPrefix] (auto path) {
+        return ignoredXPathPrefix ? path.substr(ignoredXPathPrefix->size()) : path;
+    };
+
     for (const auto& it : items) {
         if (!it)
             continue;
@@ -28,15 +32,15 @@
                 // The fact that the container is included in the data tree
                 // means that it is present and I don't need to check any
                 // value.
-                res.emplace(it->path(), special_{SpecialValue::PresenceContainer});
+                res.emplace(stripXPathPrefix(it->path()), special_{SpecialValue::PresenceContainer});
             }
         }
         if (it->schema()->nodetype() == LYS_LIST) {
-            res.emplace(it->path(), special_{SpecialValue::List});
+            res.emplace(stripXPathPrefix(it->path()), special_{SpecialValue::List});
         }
         if (it->schema()->nodetype() == LYS_LEAF) {
             libyang::Data_Node_Leaf_List leaf(it);
-            res.emplace(leaf.path(), leafValueFromValue(leaf.value(), leaf.leaf_type()->base()));
+            res.emplace(stripXPathPrefix(it->path()), leafValueFromValue(leaf.value(), leaf.leaf_type()->base()));
         }
     }
 }
@@ -141,6 +145,25 @@
     m_session->discard();
 }
 
+DatastoreAccess::Tree NetconfAccess::executeRpc(const std::string& path, const Tree& input)
+{
+    auto root = m_schema->dataNodeFromPath(path);
+    for (const auto& [k, v] : input) {
+        auto node = m_schema->dataNodeFromPath(joinPaths(path, k), leafDataToString(v));
+        root->merge(node, 0);
+    }
+    auto data = root->print_mem(LYD_XML, 0);
+
+    Tree res;
+    auto output = m_session->rpc(data);
+    if (output) {
+        for (auto it : output->tree_for()) {
+            fillMap(res, it->tree_dfs(), joinPaths(path, "/"));
+        }
+    }
+    return res;
+}
+
 std::string NetconfAccess::fetchSchema(const std::string_view module, const
         std::optional<std::string_view> revision, const
         std::optional<std::string_view> submodule, const
diff --git a/src/netconf_access.hpp b/src/netconf_access.hpp
index a5e0485..f43976a 100644
--- a/src/netconf_access.hpp
+++ b/src/netconf_access.hpp
@@ -41,6 +41,7 @@
     void deleteListInstance(const std::string& path) override;
     void commitChanges() override;
     void discardChanges() override;
+    Tree executeRpc(const std::string& path, const Tree& input) override;
 
     std::shared_ptr<Schema> schema() override;
 
diff --git a/src/sysrepo_access.cpp b/src/sysrepo_access.cpp
index 499597f..cf19407 100644
--- a/src/sysrepo_access.cpp
+++ b/src/sysrepo_access.cpp
@@ -8,6 +8,7 @@
 
 #include <sysrepo-cpp/Session.hpp>
 #include "sysrepo_access.hpp"
+#include "utils.hpp"
 #include "yang_schema.hpp"
 
 leaf_data_ leafValueFromVal(const sysrepo::S_Val& value)
@@ -83,6 +84,47 @@
     }
 };
 
+struct updateSrValFromValue : boost::static_visitor<void> {
+    std::string xpath;
+    sysrepo::S_Val v;
+    updateSrValFromValue(const std::string& xpath, sysrepo::S_Val v)
+        : xpath(xpath)
+        , v(v)
+    {
+    }
+
+    void operator()(const enum_& value) const
+    {
+        v->set(xpath.c_str(), value.m_value.c_str(), SR_ENUM_T);
+    }
+
+    void operator()(const binary_& value) const
+    {
+        v->set(xpath.c_str(), value.m_value.c_str(), SR_BINARY_T);
+    }
+
+    void operator()(const identityRef_& value) const
+    {
+        v->set(xpath.c_str(), (value.m_prefix.value().m_name + ":" + value.m_value).c_str(), SR_IDENTITYREF_T);
+    }
+
+    void operator()(const special_& value) const
+    {
+        throw std::runtime_error("Tried constructing S_Val from a " + specialValueToString(value));
+    }
+
+    void operator()(const std::string& value) const
+    {
+        v->set(xpath.c_str(), value.c_str(), SR_STRING_T);
+    }
+
+    template <typename T>
+    void operator()(const T value) const
+    {
+        v->set(xpath.c_str(), value);
+    }
+};
+
 SysrepoAccess::~SysrepoAccess() = default;
 
 SysrepoAccess::SysrepoAccess(const std::string& appname)
@@ -203,6 +245,25 @@
     }
 }
 
+DatastoreAccess::Tree SysrepoAccess::executeRpc(const std::string &path, const Tree &input)
+{
+    auto srInput = std::make_shared<sysrepo::Vals>(input.size());
+    {
+        size_t i = 0;
+        for (const auto& [k, v] : input) {
+            boost::apply_visitor(updateSrValFromValue(joinPaths(path, k), srInput->val(i)), v);
+            ++i;
+        }
+    }
+    auto output = m_session->rpc_send(path.c_str(), srInput);
+    Tree res;
+    for (size_t i = 0; i < output->val_cnt(); ++i) {
+        const auto& v = output->val(i);
+        res.emplace(std::string(v->xpath()).substr(joinPaths(path, "/").size()), leafValueFromVal(v));
+    }
+    return res;
+}
+
 std::string SysrepoAccess::fetchSchema(const char* module, const char* revision, const char* submodule)
 {
     std::string schema;
diff --git a/src/sysrepo_access.hpp b/src/sysrepo_access.hpp
index ff26e7a..c2ce909 100644
--- a/src/sysrepo_access.hpp
+++ b/src/sysrepo_access.hpp
@@ -34,6 +34,7 @@
     void deletePresenceContainer(const std::string& path) override;
     void createListInstance(const std::string& path) override;
     void deleteListInstance(const std::string& path) override;
+    Tree executeRpc(const std::string& path, const Tree& input) override;
 
     std::shared_ptr<Schema> schema() override;
 
diff --git a/tests/datastore_access.cpp b/tests/datastore_access.cpp
index 9740db6..112ace9 100644
--- a/tests/datastore_access.cpp
+++ b/tests/datastore_access.cpp
@@ -7,6 +7,7 @@
 */
 
 #include "trompeloeil_doctest.h"
+#include <sysrepo-cpp/Session.hpp>
 
 #ifdef sysrepo_BACKEND
 #include "sysrepo_access.hpp"
@@ -19,6 +20,8 @@
 #include "sysrepo_subscription.hpp"
 #include "utils.hpp"
 
+using namespace std::literals::string_literals;
+
 class MockRecorder : public trompeloeil::mock_interface<Recorder> {
 public:
     IMPLEMENT_MOCK3(write);
@@ -57,7 +60,6 @@
 #error "Unknown backend"
 #endif
 
-    using namespace std::literals::string_literals;
 
     SECTION("set leafInt8 to -128")
     {
@@ -288,3 +290,113 @@
 
     waitForCompletionAndBitMore(seq1);
 }
+
+class RpcCb: public sysrepo::Callback {
+    int rpc(const char *xpath, const ::sysrepo::S_Vals input, ::sysrepo::S_Vals_Holder output, void *) override
+    {
+        const auto nukes = "/example-schema:launch-nukes"s;
+        if (xpath == "/example-schema:noop"s) {
+            return SR_ERR_OK;
+        } else if (xpath == nukes) {
+            uint64_t kilotons = 0;
+            bool hasCities = false;
+            for (size_t i = 0; i < input->val_cnt(); ++i) {
+                const auto& val = input->val(i);
+                if (val->xpath() == nukes + "/payload/kilotons") {
+                    kilotons = val->data()->get_uint64();
+                } else if (val->xpath() == nukes + "/payload") {
+                    // ignore, container
+                } else if (val->xpath() == nukes + "/description") {
+                    // unused
+                } else if (std::string_view{val->xpath()}.find(nukes + "/cities") == 0) {
+                    hasCities = true;
+                } else {
+                    throw std::runtime_error("RPC launch-nukes: unexpected input "s + val->xpath());
+                }
+            }
+            if (kilotons == 333'666) {
+                // magic, just do not generate any output. This is important because the NETCONF RPC returns just <ok/>.
+                return SR_ERR_OK;
+            }
+            auto buf = output->allocate(2);
+            size_t i = 0;
+            buf->val(i++)->set((nukes + "/blast-radius").c_str(), uint32_t{33'666});
+            buf->val(i++)->set((nukes + "/actual-yield").c_str(), static_cast<uint64_t>(1.33 * kilotons));
+            if (hasCities) {
+                buf = output->reallocate(output->val_cnt() + 2);
+                buf->val(i++)->set((nukes + "/damaged-places/targets[city='London']/city").c_str(), "London");
+                buf->val(i++)->set((nukes + "/damaged-places/targets[city='Berlin']/city").c_str(), "Berlin");
+            }
+            return SR_ERR_OK;
+        }
+        throw std::runtime_error("unrecognized RPC");
+    }
+};
+
+TEST_CASE("rpc") {
+    trompeloeil::sequence seq1;
+    auto srConn = std::make_shared<sysrepo::Connection>("netconf-cli-test-rpc");
+    auto srSession = std::make_shared<sysrepo::Session>(srConn);
+    auto srSubscription = std::make_shared<sysrepo::Subscribe>(srSession);
+    auto cb = std::make_shared<RpcCb>();
+    sysrepo::Logs{}.set_stderr(SR_LL_INF);
+    srSubscription->rpc_subscribe("/example-schema:noop", cb, nullptr, SR_SUBSCR_CTX_REUSE);
+    srSubscription->rpc_subscribe("/example-schema:launch-nukes", cb, nullptr, SR_SUBSCR_CTX_REUSE);
+
+#ifdef sysrepo_BACKEND
+    SysrepoAccess datastore("netconf-cli-test");
+#elif defined(netconf_BACKEND)
+    NetconfAccess datastore(NETOPEER_SOCKET_PATH);
+#else
+#error "Unknown backend"
+#endif
+
+    std::string rpc;
+    DatastoreAccess::Tree input, output;
+
+    SECTION("noop") {
+        rpc = "/example-schema:noop";
+    }
+
+    SECTION("small nuke") {
+        rpc = "/example-schema:launch-nukes";
+        input = {
+            {"description", "dummy"s},
+            {"payload/kilotons", uint64_t{333'666}},
+        };
+        // no data are returned
+    }
+
+    SECTION("small nuke") {
+        rpc = "/example-schema:launch-nukes";
+        input = {
+            {"description", "dummy"s},
+            {"payload/kilotons", uint64_t{4}},
+        };
+        output = {
+            {"blast-radius", uint32_t{33'666}},
+            {"actual-yield", uint64_t{5}},
+        };
+    }
+
+    SECTION("with lists") {
+        rpc = "/example-schema:launch-nukes";
+        input = {
+            {"payload/kilotons", uint64_t{6}},
+            {"cities/targets[city='Prague']/city", "Prague"s},
+        };
+        output = {
+            {"blast-radius", uint32_t{33'666}},
+            {"actual-yield", uint64_t{7}},
+            {"damaged-places", special_{SpecialValue::PresenceContainer}},
+            {"damaged-places/targets[city='London']", special_{SpecialValue::List}},
+            {"damaged-places/targets[city='London']/city", "London"s},
+            {"damaged-places/targets[city='Berlin']", special_{SpecialValue::List}},
+            {"damaged-places/targets[city='Berlin']/city", "Berlin"s},
+        };
+    }
+
+    REQUIRE(datastore.executeRpc(rpc, input) == output);
+
+    waitForCompletionAndBitMore(seq1);
+}
diff --git a/tests/example-schema.yang b/tests/example-schema.yang
index 498446e..d993781 100644
--- a/tests/example-schema.yang
+++ b/tests/example-schema.yang
@@ -83,4 +83,48 @@
     container lol {
         uses upAndDown;
     }
+
+    grouping targets_def {
+        list targets {
+            key 'city';
+            leaf city {
+                type string;
+            }
+        }
+    }
+
+    rpc launch-nukes {
+        input {
+            container payload {
+                leaf kilotons {
+                    type uint64;
+                    mandatory true;
+                    units "kilotons";
+                }
+            }
+            leaf description {
+                type string;
+            }
+            container cities {
+                presence true;
+                uses targets_def;
+            }
+        }
+        output {
+            leaf blast-radius {
+                type uint32;
+                units "m";
+            }
+            leaf actual-yield {
+                type uint64;
+                units "kilotons";
+            }
+            container damaged-places {
+                presence true;
+                uses targets_def;
+            }
+        }
+    }
+
+    rpc noop {}
 }