From: mpriyank Date: Fri, 18 Jul 2025 13:38:15 +0000 (+0100) Subject: Utility to convert restconf style path to cps path X-Git-Tag: 3.7.0~7^2 X-Git-Url: https://gerrit.onap.org/r/gitweb?a=commitdiff_plain;h=f9599479234516aaf737c9ca02910fe0120447d8;p=cps.git Utility to convert restconf style path to cps path - 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 --- 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 index 0000000000..7b575c3894 --- /dev/null +++ b/cps-service/src/main/java/org/onap/cps/utils/RestConfStylePathToCpsPathUtil.java @@ -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 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 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 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 index 0000000000..789524c015 --- /dev/null +++ b/cps-service/src/test/groovy/org/onap/cps/utils/RestConfStylePathToCpsPathUtilSpec.groovy @@ -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') + } +}