Utility to convert restconf style path to cps path 75/141575/5
authormpriyank <priyank.maheshwari@est.tech>
Fri, 18 Jul 2025 13:38:15 +0000 (14:38 +0100)
committermpriyank <priyank.maheshwari@est.tech>
Mon, 28 Jul 2025 12:12:37 +0000 (13:12 +0100)
- resource-identifier in passthrough request has restconf style path for
  the devices
- when listening cm avc event on successfull write operation , ncmp has
  to update the cache, hence the path needs to be converted to cps path
  to reuse cps-core services
- added testware to support this
- actual usage of this utility is in the next patch
- for simplicity, utility supports just one key in the path

Issue-ID: CPS-2759
Change-Id: Id3b6e629d134abc0c6b21117f5f157da90248dee
Signed-off-by: mpriyank <priyank.maheshwari@est.tech>
cps-service/src/main/java/org/onap/cps/utils/RestConfStylePathToCpsPathUtil.java [new file with mode: 0644]
cps-service/src/test/groovy/org/onap/cps/utils/RestConfStylePathToCpsPathUtilSpec.groovy [new file with mode: 0644]

diff --git a/cps-service/src/main/java/org/onap/cps/utils/RestConfStylePathToCpsPathUtil.java b/cps-service/src/main/java/org/onap/cps/utils/RestConfStylePathToCpsPathUtil.java
new file mode 100644 (file)
index 0000000..7b575c3
--- /dev/null
@@ -0,0 +1,114 @@
+/*
+ * ============LICENSE_START=======================================================
+ * Copyright (c) 2025 OpenInfra Foundation Europe. All rights reserved.
+ * ================================================================================
+ * 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.util.Collection;
+import java.util.List;
+import lombok.AccessLevel;
+import lombok.NoArgsConstructor;
+import org.opendaylight.yangtools.yang.common.QName;
+import org.opendaylight.yangtools.yang.model.api.DataNodeContainer;
+import org.opendaylight.yangtools.yang.model.api.DataSchemaNode;
+import org.opendaylight.yangtools.yang.model.api.ListSchemaNode;
+import org.opendaylight.yangtools.yang.model.api.SchemaContext;
+
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+public final class RestConfStylePathToCpsPathUtil {
+
+    private static final String NO_KEY_VALUE_IN_PATH_SEGMENT = null;
+
+    /**
+     * Convert RESTCONF style path to CpsPath.
+     *
+     * @param restConfStylePath restconf style path
+     * @param schemaContext     schema context
+     * @return CpsPath
+     */
+    public static String convertToCpsPath(final String restConfStylePath, final SchemaContext schemaContext) {
+        if (restConfStylePath == null || restConfStylePath.trim().isEmpty()) {
+            return "";
+        }
+
+        final StringBuilder cpsPathStringBuilder = new StringBuilder();
+        Collection<? extends DataSchemaNode> currentSchemaNodes = schemaContext.getChildNodes();
+
+        for (final String restConfPathSegment : restConfStylePath.split("/")) {
+            if (restConfPathSegment.isEmpty()) {
+                continue;
+            }
+
+            final PathSegment pathSegment = parsePathSegment(restConfPathSegment);
+            final DataSchemaNode matchingDataSchemaNode =
+                    findMatchingDataSchemaNode(pathSegment.nodeName, currentSchemaNodes);
+            buildCpsPath(pathSegment, matchingDataSchemaNode, cpsPathStringBuilder);
+            currentSchemaNodes =
+                    (matchingDataSchemaNode instanceof DataNodeContainer container) ? container.getChildNodes()
+                            : List.of();
+        }
+
+        return cpsPathStringBuilder.toString();
+    }
+
+    private static void buildCpsPath(final PathSegment pathSegment, final DataSchemaNode dataSchemaNode,
+            final StringBuilder cpsPathStringBuilder) {
+        cpsPathStringBuilder.append("/").append(pathSegment.nodeName);
+
+        if (pathSegment.keyValue != null && dataSchemaNode instanceof ListSchemaNode listNode) {
+            final String keyFilter = buildKeyFilter(listNode, pathSegment.keyValue);
+            cpsPathStringBuilder.append(keyFilter);
+        }
+    }
+
+    private static PathSegment parsePathSegment(final String restConfPathSegment) {
+        // Strip module prefix (e.g., "stores:bookstore" -> "bookstore")
+        final String restConfPathSegmentWithoutModulePrefix =
+                restConfPathSegment.contains(":") ? restConfPathSegment.substring(restConfPathSegment.indexOf(":") + 1)
+                        : restConfPathSegment;
+
+        if (restConfPathSegmentWithoutModulePrefix.contains("=")) {
+            final String[] parts = restConfPathSegmentWithoutModulePrefix.split("=", 2);
+            return new PathSegment(parts[0], parts[1]);
+        }
+
+        return new PathSegment(restConfPathSegmentWithoutModulePrefix, NO_KEY_VALUE_IN_PATH_SEGMENT);
+    }
+
+    private static DataSchemaNode findMatchingDataSchemaNode(final String nodeName,
+            final Collection<? extends DataSchemaNode> dataSchemaNodes) {
+        return dataSchemaNodes.stream().filter(schemaNode -> nodeName.equals(schemaNode.getQName().getLocalName()))
+                       .findFirst()
+                       .orElseThrow(() -> new IllegalArgumentException("Data Schema node not found: " + nodeName));
+    }
+
+    private static String buildKeyFilter(final ListSchemaNode listSchemaNode, final String keyValue) {
+        final List<QName> keyQNames = listSchemaNode.getKeyDefinition();
+        if (keyQNames.isEmpty()) {
+            throw new IllegalArgumentException(
+                    "No key defined for list node: " + listSchemaNode.getQName().getLocalName());
+        }
+
+        final String keyName = keyQNames.get(0).getLocalName(); // Only first key supported for now
+        return "[@" + keyName + "='" + keyValue + "']";
+    }
+
+    private record PathSegment(String nodeName, String keyValue) { }
+
+}
diff --git a/cps-service/src/test/groovy/org/onap/cps/utils/RestConfStylePathToCpsPathUtilSpec.groovy b/cps-service/src/test/groovy/org/onap/cps/utils/RestConfStylePathToCpsPathUtilSpec.groovy
new file mode 100644 (file)
index 0000000..789524c
--- /dev/null
@@ -0,0 +1,75 @@
+/*
+ * ============LICENSE_START=======================================================
+ * Copyright (c) 2025 OpenInfra Foundation Europe. All rights reserved.
+ * ================================================================================
+ * 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 org.onap.cps.TestUtils
+import org.onap.cps.yang.YangTextSchemaSourceSetBuilder
+import org.opendaylight.yangtools.yang.common.QName
+import org.opendaylight.yangtools.yang.model.api.ListSchemaNode
+import org.opendaylight.yangtools.yang.model.api.SchemaContext
+import spock.lang.Specification
+
+import static RestConfStylePathToCpsPathUtil.convertToCpsPath
+
+class RestConfStylePathToCpsPathUtilSpec extends Specification {
+
+    SchemaContext schemaContext
+
+    def setup() {
+        def yangResources = TestUtils.getYangResourcesAsMap('bookstore.yang')
+        schemaContext = YangTextSchemaSourceSetBuilder.of(yangResources).getSchemaContext()
+    }
+
+    def 'Convert RestConf style paths when (#scenario) to CPS paths'() {
+        expect: 'the path to be correctly converted'
+            assert convertToCpsPath(inputPath, schemaContext) == expectedPath
+        where: 'following scenarios are used'
+            scenario                                              | inputPath                                                || expectedPath
+            'Nested path with multiple keyed lists'               | '/stores:bookstore/categories=fiction/books=WarAndPeace' || '/bookstore/categories[@code=\'fiction\']/books[@title=\'WarAndPeace\']'
+            'Single keyed list path with module prefix'           | '/book-store:bookstore/book-store:categories=fiction'    || '/bookstore/categories[@code=\'fiction\']'
+            'Path to leaf node under container'                   | '/book-store:bookstore/book-store:bookstore-name'        || '/bookstore/bookstore-name'
+            'Keyed path where node is not a list (no key filter)' | '/book-store:bookstore/book-store:bookstore-name=value'  || '/bookstore/bookstore-name'
+            'Null input path returns empty result'                | null                                                     || ''
+            'Blank input path returns empty result'               | '   '                                                    || ''
+    }
+
+    def 'Throw error for unknown segment in valid path'() {
+        when: 'we have an unknown segment in the restconf style path'
+            convertToCpsPath('/stores:bookstore/unknown-segment-of-path', schemaContext)
+        then: 'exception is thrown'
+            def exception = thrown(IllegalArgumentException)
+            assert exception.message.contains('Data Schema node not found')
+    }
+
+    def 'Should throw exception if list node has no key'() {
+        given: 'mocked books as list schema node'
+            def booksQName = QName.create('org:onap:ccsdk:sample', '2020-09-15', 'books')
+            def books = Mock(ListSchemaNode) {
+                getQName() >> booksQName
+                getKeyDefinition() >> []
+            }
+        when: 'path with no key(field preceded by @ eg : @code, @name etc ) is used'
+            RestConfStylePathToCpsPathUtil.buildKeyFilter(books, '/books=no-key')
+        then: 'exception is thrown'
+            def exception = thrown(IllegalArgumentException)
+            assert exception.message.contains('No key defined')
+    }
+}