From 92c5d8ae3448e046dd132b35811cf3ef8b5325d3 Mon Sep 17 00:00:00 2001 From: ToineSiebelink Date: Thu, 11 Dec 2025 16:10:20 +0000 Subject: [PATCH] Refactor ProvMnS error handling and simplify request parameters This update covers several tickets: CPS-3078, CPS-3093, CPS-3095, CPS-3096, CPS-3099 - Consolidate error handling in ProvMnSRestExceptionHandler - changed reason to title - Remove ErrorResponseBuilder and integrate functionality into exception handler - Refactor ProvMnsController and centralize error handling into 1 common method. including - correct code for Policy Executor denial - handle timeouts - handle all other exceptions - handle #/attribute reference validation depending on content type - Enhance ProvMnSException with additional error handling capabilities use ProvMnSException to store the information to create the correct error response - Simplify OperationDetailsFactory implementation TODO: Remove docker config updates for debugging TODO Lee Anjella - Check if a mapping to type is available for every possible statuscode in provMnsExceptions - COVREAGE 100% MIsine exception in ParameterMapper.java Issue-ID: CPS-3093 Change-Id: Ic5ed5a8502ebf74166c73c037884979f468cdb8d Signed-off-by: ToineSiebelink --- .../NetworkCmProxyRestExceptionHandler.java | 56 ++- .../ncmp/rest/controller/ProvMnSController.java | 249 +++++++++++++ .../controller/ProvMnSRestExceptionHandler.java | 92 +++++ .../ncmp/rest/controller/ProvMnsController.java | 219 ------------ .../ncmp/rest/provmns/ErrorResponseBuilder.java | 83 ----- .../NetworkCmProxyRestExceptionHandlerSpec.groovy | 17 +- .../rest/controller/ProvMnSControllerSpec.groovy | 392 +++++++++++++++++++++ .../rest/controller/ProvMnsControllerSpec.groovy | 352 ------------------ .../cps/ncmp/api/exceptions/ProvMnSException.java | 28 +- .../policyexecutor/OperationDetailsFactory.java | 153 +++----- .../cps/ncmp/impl/provmns/ParameterMapper.java | 40 ++- .../cps/ncmp/impl/provmns/ParametersBuilder.java | 37 +- ...tPathParameters.java => RequestParameters.java} | 9 +- .../OperationDetailsFactorySpec.groovy | 128 ++----- .../ncmp/impl/provmns/ParameterMapperSpec.groovy | 20 +- .../ncmp/impl/provmns/ParametersBuilderSpec.groovy | 47 +-- .../ncmp/impl/provmns/RequestParametersSpec.groovy | 42 +++ cps-parent/pom.xml | 2 +- docker-compose/config/nginx/nginx.conf | 2 +- docker-compose/docker-compose.yml | 38 +- 20 files changed, 1050 insertions(+), 956 deletions(-) create mode 100644 cps-ncmp-rest/src/main/java/org/onap/cps/ncmp/rest/controller/ProvMnSController.java create mode 100644 cps-ncmp-rest/src/main/java/org/onap/cps/ncmp/rest/controller/ProvMnSRestExceptionHandler.java delete mode 100644 cps-ncmp-rest/src/main/java/org/onap/cps/ncmp/rest/controller/ProvMnsController.java delete mode 100644 cps-ncmp-rest/src/main/java/org/onap/cps/ncmp/rest/provmns/ErrorResponseBuilder.java create mode 100644 cps-ncmp-rest/src/test/groovy/org/onap/cps/ncmp/rest/controller/ProvMnSControllerSpec.groovy delete mode 100644 cps-ncmp-rest/src/test/groovy/org/onap/cps/ncmp/rest/controller/ProvMnsControllerSpec.groovy rename cps-ncmp-service/src/main/java/org/onap/cps/ncmp/impl/provmns/{RequestPathParameters.java => RequestParameters.java} (85%) create mode 100644 cps-ncmp-service/src/test/groovy/org/onap/cps/ncmp/impl/provmns/RequestParametersSpec.groovy diff --git a/cps-ncmp-rest/src/main/java/org/onap/cps/ncmp/rest/controller/NetworkCmProxyRestExceptionHandler.java b/cps-ncmp-rest/src/main/java/org/onap/cps/ncmp/rest/controller/NetworkCmProxyRestExceptionHandler.java index 0b04432dd5..c40c6fc4a0 100644 --- a/cps-ncmp-rest/src/main/java/org/onap/cps/ncmp/rest/controller/NetworkCmProxyRestExceptionHandler.java +++ b/cps-ncmp-rest/src/main/java/org/onap/cps/ncmp/rest/controller/NetworkCmProxyRestExceptionHandler.java @@ -1,7 +1,7 @@ /* * ============LICENSE_START======================================================= * Copyright (C) 2021 Pantheon.tech - * Modifications Copyright (C) 2021-2024 Nordix Foundation + * Modifications Copyright (C) 2021-2025 OpenInfra Foundation Europe * ================================================================================ * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -37,7 +37,6 @@ import org.onap.cps.ncmp.api.exceptions.InvalidTopicException; import org.onap.cps.ncmp.api.exceptions.NcmpException; import org.onap.cps.ncmp.api.exceptions.PayloadTooLargeException; import org.onap.cps.ncmp.api.exceptions.PolicyExecutorException; -import org.onap.cps.ncmp.api.exceptions.ProvMnSException; import org.onap.cps.ncmp.api.exceptions.ServerNcmpException; import org.onap.cps.ncmp.rest.model.DmiErrorMessage; import org.onap.cps.ncmp.rest.model.DmiErrorMessageDmiResponse; @@ -52,35 +51,52 @@ import org.springframework.web.bind.annotation.RestControllerAdvice; * Exception handler with error message return. */ @Slf4j -@RestControllerAdvice(assignableTypes = {NetworkCmProxyController.class, - NetworkCmProxyInventoryController.class}) +@RestControllerAdvice(assignableTypes = { NetworkCmProxyController.class, NetworkCmProxyInventoryController.class }) @NoArgsConstructor(access = AccessLevel.PACKAGE) public class NetworkCmProxyRestExceptionHandler { private static final String CHECK_LOGS_FOR_DETAILS = "Check logs for details."; /** - * Default exception handler. + * Default exception handler for internal server errors. * * @param exception the exception to handle - * @return response with response code 500. + * @return response with HTTP 500 status */ @ExceptionHandler public static ResponseEntity handleInternalServerErrorExceptions(final Exception exception) { return buildErrorResponse(HttpStatus.INTERNAL_SERVER_ERROR, exception); } + /** + * Exception handler for CPS and server NCMP exceptions. + * + * @param exception the exception to handle + * @return response with HTTP 500 status + */ @ExceptionHandler({CpsException.class, ServerNcmpException.class}) public static ResponseEntity handleAnyOtherCpsExceptions(final Exception exception) { return buildErrorResponse(HttpStatus.INTERNAL_SERVER_ERROR, exception); } + /** + * Exception handler for DMI client request exceptions. + * + * @param dmiClientRequestException the DMI client request exception to handle + * @return response with HTTP 502 status containing DMI error details + */ @ExceptionHandler({DmiClientRequestException.class}) - public static ResponseEntity handleClientRequestExceptions( + public static ResponseEntity handleDmiClientRequestExceptions( final DmiClientRequestException dmiClientRequestException) { return wrapDmiErrorResponse(dmiClientRequestException); } + /** + * Exception handler for various request validation and operation exceptions. + * + * @param exception the exception to handle + * @return response with HTTP 400 status + */ @ExceptionHandler({DmiRequestException.class, DataValidationException.class, InvalidOperationException.class, OperationNotSupportedException.class, HttpMessageNotReadableException.class, InvalidTopicException.class, InvalidDatastoreException.class}) @@ -88,26 +104,39 @@ public class NetworkCmProxyRestExceptionHandler { return buildErrorResponse(HttpStatus.BAD_REQUEST, exception); } + /** + * Exception handler for conflict exceptions. + * + * @param exception the exception to handle + * @return response with HTTP 409 status + */ @ExceptionHandler({AlreadyDefinedException.class, PolicyExecutorException.class}) public static ResponseEntity handleConflictExceptions(final Exception exception) { return buildErrorResponse(HttpStatus.CONFLICT, exception); } + /** + * Exception handler for CM handle and data node not found exceptions. + * + * @param exception the exception to handle + * @return response with HTTP 404 status + */ @ExceptionHandler({CmHandleNotFoundException.class, DataNodeNotFoundException.class}) public static ResponseEntity cmHandleNotFoundExceptions(final Exception exception) { return buildErrorResponse(HttpStatus.NOT_FOUND, exception); } + /** + * Exception handler for payload too large exceptions. + * + * @param exception the exception to handle + * @return response with HTTP 413 status + */ @ExceptionHandler({PayloadTooLargeException.class}) public static ResponseEntity handlePayloadTooLargeExceptions(final Exception exception) { return buildErrorResponse(HttpStatus.PAYLOAD_TOO_LARGE, exception); } - @ExceptionHandler({ProvMnSException.class}) - public static ResponseEntity invalidPathExceptions(final Exception exception) { - return buildErrorResponse(HttpStatus.UNPROCESSABLE_ENTITY, exception); - } - private static ResponseEntity buildErrorResponse(final HttpStatus status, final Exception exception) { if (exception.getCause() != null || !(exception instanceof CpsException)) { log.error("Exception occurred", exception); @@ -126,7 +155,7 @@ public class NetworkCmProxyRestExceptionHandler { } private static ResponseEntity wrapDmiErrorResponse(final DmiClientRequestException - dmiClientRequestException) { + dmiClientRequestException) { final var dmiErrorMessage = new DmiErrorMessage(); final var dmiErrorResponse = new DmiErrorMessageDmiResponse(); dmiErrorResponse.setHttpCode(dmiClientRequestException.getHttpStatusCode()); @@ -135,4 +164,5 @@ public class NetworkCmProxyRestExceptionHandler { dmiErrorMessage.setDmiResponse(dmiErrorResponse); return new ResponseEntity<>(dmiErrorMessage, HttpStatus.BAD_GATEWAY); } + } diff --git a/cps-ncmp-rest/src/main/java/org/onap/cps/ncmp/rest/controller/ProvMnSController.java b/cps-ncmp-rest/src/main/java/org/onap/cps/ncmp/rest/controller/ProvMnSController.java new file mode 100644 index 0000000000..f2c6ee3751 --- /dev/null +++ b/cps-ncmp-rest/src/main/java/org/onap/cps/ncmp/rest/controller/ProvMnSController.java @@ -0,0 +1,249 @@ +/* + * ============LICENSE_START======================================================= + * Copyright (C) 2025 OpenInfra Foundation Europe + * ================================================================================ + * 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.ncmp.rest.controller; + +import static org.onap.cps.ncmp.api.data.models.OperationType.CREATE; +import static org.onap.cps.ncmp.api.data.models.OperationType.DELETE; +import static org.onap.cps.ncmp.impl.models.RequiredDmiService.DATA; + +import io.netty.handler.timeout.TimeoutException; +import jakarta.servlet.http.HttpServletRequest; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.onap.cps.api.exceptions.DataValidationException; +import org.onap.cps.ncmp.api.data.models.OperationType; +import org.onap.cps.ncmp.api.exceptions.PolicyExecutorException; +import org.onap.cps.ncmp.api.exceptions.ProvMnSException; +import org.onap.cps.ncmp.api.inventory.models.CmHandleState; +import org.onap.cps.ncmp.exceptions.NoAlternateIdMatchFoundException; +import org.onap.cps.ncmp.impl.data.policyexecutor.CreateOperationDetails; +import org.onap.cps.ncmp.impl.data.policyexecutor.DeleteOperationDetails; +import org.onap.cps.ncmp.impl.data.policyexecutor.OperationDetails; +import org.onap.cps.ncmp.impl.data.policyexecutor.OperationDetailsFactory; +import org.onap.cps.ncmp.impl.data.policyexecutor.PolicyExecutor; +import org.onap.cps.ncmp.impl.dmi.DmiRestClient; +import org.onap.cps.ncmp.impl.inventory.InventoryPersistence; +import org.onap.cps.ncmp.impl.inventory.models.YangModelCmHandle; +import org.onap.cps.ncmp.impl.provmns.ParameterMapper; +import org.onap.cps.ncmp.impl.provmns.ParametersBuilder; +import org.onap.cps.ncmp.impl.provmns.RequestParameters; +import org.onap.cps.ncmp.impl.provmns.model.ClassNameIdGetDataNodeSelectorParameter; +import org.onap.cps.ncmp.impl.provmns.model.PatchItem; +import org.onap.cps.ncmp.impl.provmns.model.Resource; +import org.onap.cps.ncmp.impl.provmns.model.Scope; +import org.onap.cps.ncmp.impl.utils.AlternateIdMatcher; +import org.onap.cps.ncmp.impl.utils.http.UrlTemplateParameters; +import org.onap.cps.utils.JsonObjectMapper; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("${rest.api.provmns-base-path}") +@RequiredArgsConstructor +@Slf4j +public class ProvMnSController implements ProvMnS { + + private static final Pattern STANDARD_ATTRIBUTE_PATTERN = Pattern.compile("[^#]/attributes"); + private static final String NO_AUTHORIZATION = null; + private static final String PROVMNS_NOT_SUPPORTED_ERROR_MESSAGE = + "Registered DMI does not support the ProvMnS interface."; + + private final AlternateIdMatcher alternateIdMatcher; + private final DmiRestClient dmiRestClient; + private final InventoryPersistence inventoryPersistence; + private final ParametersBuilder parametersBuilder; + private final ParameterMapper parameterMapper; + private final PolicyExecutor policyExecutor; + private final JsonObjectMapper jsonObjectMapper; + private final OperationDetailsFactory operationDetailsFactory; + + @Value("${app.ncmp.provmns.max-patch-operations:10}") + private Integer maxNumberOfPatchOperations; + + @Override + public ResponseEntity getMoi(final HttpServletRequest httpServletRequest, + final Scope scope, + final String filter, + final List attributes, + final List fields, + final ClassNameIdGetDataNodeSelectorParameter dataNodeSelector) { + final RequestParameters requestParameters = parameterMapper.extractRequestParameters(httpServletRequest); + try { + final YangModelCmHandle yangModelCmHandle = getAndValidateYangModelCmHandle(requestParameters); + final String targetFdn = requestParameters.toTargetFdn(); + final UrlTemplateParameters urlTemplateParameters = parametersBuilder.createUrlTemplateParametersForRead( + yangModelCmHandle, targetFdn, scope, filter, attributes, fields, dataNodeSelector); + return dmiRestClient.synchronousGetOperation(DATA, urlTemplateParameters); + } catch (final Throwable throwable) { + throw toProvMnSException(httpServletRequest.getMethod(), throwable); + } + } + + @Override + public ResponseEntity patchMoi(final HttpServletRequest httpServletRequest, + final List patchItems) { + if (patchItems.size() > maxNumberOfPatchOperations) { + final String title = patchItems.size() + " operations in request, this exceeds the maximum of " + + maxNumberOfPatchOperations; + throw new ProvMnSException(httpServletRequest.getMethod(), HttpStatus.PAYLOAD_TOO_LARGE, title); + } + final RequestParameters requestParameters = parameterMapper.extractRequestParameters(httpServletRequest); + for (final PatchItem patchItem : patchItems) { + validateAttributeReference(httpServletRequest.getMethod(), httpServletRequest.getContentType(), patchItem); + } + try { + final YangModelCmHandle yangModelCmHandle = getAndValidateYangModelCmHandle(requestParameters); + checkPermissionForEachPatchItem(requestParameters, patchItems, yangModelCmHandle); + final String targetFdn = requestParameters.toTargetFdn(); + final UrlTemplateParameters urlTemplateParameters = + parametersBuilder.createUrlTemplateParametersForWrite(yangModelCmHandle, targetFdn); + return dmiRestClient.synchronousPatchOperation(DATA, patchItems, urlTemplateParameters, + httpServletRequest.getContentType()); + } catch (final Throwable throwable) { + throw toProvMnSException(httpServletRequest.getMethod(), throwable); + } + } + + @Override + public ResponseEntity putMoi(final HttpServletRequest httpServletRequest, final Resource resource) { + final RequestParameters requestParameters = parameterMapper.extractRequestParameters(httpServletRequest); + try { + final YangModelCmHandle yangModelCmHandle = getAndValidateYangModelCmHandle(requestParameters); + final CreateOperationDetails createOperationDetails = + operationDetailsFactory.buildCreateOperationDetails(CREATE, requestParameters, resource); + checkPermission(yangModelCmHandle, CREATE, requestParameters.toTargetFdn(), createOperationDetails); + final String targetFdn = requestParameters.toTargetFdn(); + final UrlTemplateParameters urlTemplateParameters = + parametersBuilder.createUrlTemplateParametersForWrite(yangModelCmHandle, targetFdn); + return dmiRestClient.synchronousPutOperation(DATA, resource, urlTemplateParameters); + } catch (final Throwable throwable) { + throw toProvMnSException(httpServletRequest.getMethod(), throwable); + } + } + + @Override + public ResponseEntity deleteMoi(final HttpServletRequest httpServletRequest) { + final RequestParameters requestParameters = parameterMapper.extractRequestParameters(httpServletRequest); + try { + final YangModelCmHandle yangModelCmHandle = getAndValidateYangModelCmHandle(requestParameters); + final DeleteOperationDetails deleteOperationDetails = + operationDetailsFactory.buildDeleteOperationDetails(requestParameters.toTargetFdn()); + checkPermission(yangModelCmHandle, DELETE, requestParameters.toTargetFdn(), deleteOperationDetails); + final String targetFdn = requestParameters.toTargetFdn(); + final UrlTemplateParameters urlTemplateParameters = + parametersBuilder.createUrlTemplateParametersForWrite(yangModelCmHandle, targetFdn); + return dmiRestClient.synchronousDeleteOperation(DATA, urlTemplateParameters); + } catch (final Throwable throwable) { + throw toProvMnSException(httpServletRequest.getMethod(), throwable); + } + } + + private YangModelCmHandle getAndValidateYangModelCmHandle(final RequestParameters requestParameters) + throws ProvMnSException { + final String alternateId = requestParameters.toTargetFdn(); + try { + final String cmHandleId = alternateIdMatcher.getCmHandleIdByLongestMatchingAlternateId(alternateId, "/"); + final YangModelCmHandle yangModelCmHandle = inventoryPersistence.getYangModelCmHandle(cmHandleId); + if (!StringUtils.hasText(yangModelCmHandle.getDataProducerIdentifier())) { + throw new ProvMnSException(requestParameters.getHttpMethodName(), HttpStatus.UNPROCESSABLE_ENTITY, + PROVMNS_NOT_SUPPORTED_ERROR_MESSAGE); + } + if (yangModelCmHandle.getCompositeState().getCmHandleState() != CmHandleState.READY) { + final String title = yangModelCmHandle.getId() + " is not in READY state. Current state: " + + yangModelCmHandle.getCompositeState().getCmHandleState().name(); + throw new ProvMnSException(requestParameters.getHttpMethodName(), HttpStatus.NOT_ACCEPTABLE, title); + } + return yangModelCmHandle; + } catch (final NoAlternateIdMatchFoundException noAlternateIdMatchFoundException) { + final String title = alternateId + " not found"; + throw new ProvMnSException(requestParameters.getHttpMethodName(), HttpStatus.NOT_FOUND, title); + } + } + + private void checkPermission(final YangModelCmHandle yangModelCmHandle, + final OperationType operationType, + final String alternateId, + final OperationDetails operationDetails) { + final String operationDetailsAsJson = jsonObjectMapper.asJsonString(operationDetails); + policyExecutor.checkPermission(yangModelCmHandle, operationType, NO_AUTHORIZATION, alternateId, + operationDetailsAsJson); + } + + private void checkPermissionForEachPatchItem(final RequestParameters requestParameters, + final List patchItems, + final YangModelCmHandle yangModelCmHandle) { + for (final PatchItem patchItem : patchItems) { + final OperationDetails operationDetails = + operationDetailsFactory.buildOperationDetails(requestParameters, patchItem); + final OperationType operationType = OperationType.fromOperationName(operationDetails.operation()); + checkPermission(yangModelCmHandle, operationType, requestParameters.toTargetFdn(), operationDetails); + } + } + + private ProvMnSException toProvMnSException(final String httpMethodName, final Throwable throwable) { + if (throwable instanceof ProvMnSException) { + return (ProvMnSException) throwable; + } + final ProvMnSException provMnSException = new ProvMnSException(); + provMnSException.setHttpMethodName(httpMethodName); + provMnSException.setTitle(throwable.getMessage()); + final HttpStatus httpStatus; + if (throwable instanceof PolicyExecutorException) { + httpStatus = HttpStatus.CONFLICT; + } else if (throwable instanceof DataValidationException) { + httpStatus = HttpStatus.BAD_REQUEST; + } else if (throwable.getCause() instanceof TimeoutException) { + httpStatus = HttpStatus.GATEWAY_TIMEOUT; + } else { + httpStatus = HttpStatus.INTERNAL_SERVER_ERROR; + } + provMnSException.setHttpStatus(httpStatus); + log.warn("ProvMns Exception: {}", provMnSException.getTitle()); + return provMnSException; + } + + private void validateAttributeReference(final String httpMethodName, + final String contentType, + final PatchItem patchItem) { + final String path = patchItem.getPath(); + boolean attributesReferenceIncorrect = false; + if (path.contains("#/attributes") && "application/json-patch+json".equals(contentType)) { + attributesReferenceIncorrect = true; + } else { + final Matcher matcher = STANDARD_ATTRIBUTE_PATTERN.matcher(path); + if ("application/3gpp-json-patch+json".equals(contentType) && matcher.find()) { + attributesReferenceIncorrect = true; + } + } + if (attributesReferenceIncorrect) { + throw new ProvMnSException(httpMethodName, HttpStatus.BAD_REQUEST, + "Invalid path for content-type " + contentType); + } + } + +} diff --git a/cps-ncmp-rest/src/main/java/org/onap/cps/ncmp/rest/controller/ProvMnSRestExceptionHandler.java b/cps-ncmp-rest/src/main/java/org/onap/cps/ncmp/rest/controller/ProvMnSRestExceptionHandler.java new file mode 100644 index 0000000000..842483a17f --- /dev/null +++ b/cps-ncmp-rest/src/main/java/org/onap/cps/ncmp/rest/controller/ProvMnSRestExceptionHandler.java @@ -0,0 +1,92 @@ +/* + * ============LICENSE_START======================================================= + * Modifications Copyright (C) 2025 OpenInfra Foundation Europe + * ================================================================================ + * 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.ncmp.rest.controller; + +import java.util.Map; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.onap.cps.ncmp.api.exceptions.ProvMnSException; +import org.onap.cps.ncmp.impl.provmns.model.ErrorResponseDefault; +import org.onap.cps.ncmp.impl.provmns.model.ErrorResponseGet; +import org.onap.cps.ncmp.impl.provmns.model.ErrorResponsePatch; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +/** + * Exception handler for ProvMns Controller only. + */ +@Slf4j +@RestControllerAdvice(assignableTypes = { ProvMnSController.class}) +@NoArgsConstructor(access = AccessLevel.PACKAGE) +public class ProvMnSRestExceptionHandler { + + private static final Map PROVMNS_ERROR_TYPE_PER_ERROR_CODE = Map.of( + HttpStatus.NOT_FOUND, "IE_NOT_FOUND", + HttpStatus.NOT_ACCEPTABLE, "APPLICATION_LAYER_ERROR", + HttpStatus.UNPROCESSABLE_ENTITY, "SERVER_LIMITATION", + HttpStatus.PAYLOAD_TOO_LARGE, "SERVER_LIMITATION" + ); + + /** + * Exception handler for ProvMnS exceptions with method-specific error responses. + * + * @param provMnSException the ProvMnS exception to handle + * @return response with appropriate HTTP status and method-specific error format + */ + @ExceptionHandler({ProvMnSException.class}) + public static ResponseEntity handleProvMnsExceptions(final ProvMnSException provMnSException) { + switch (provMnSException.getHttpMethodName()) { + case "PATCH": + return provMnSErrorResponsePatch(provMnSException.getHttpStatus(), provMnSException.getTitle()); + case "GET": + return provMnSErrorResponseGet(provMnSException.getHttpStatus(), provMnSException.getTitle()); + default: + return provMnSErrorResponseDefault(provMnSException.getHttpStatus(), provMnSException.getTitle()); + } + } + + private static ResponseEntity provMnSErrorResponsePatch(final HttpStatus httpStatus, final String title) { + final String type = PROVMNS_ERROR_TYPE_PER_ERROR_CODE.get(httpStatus); + final ErrorResponsePatch errorResponsePatch = new ErrorResponsePatch(type); + errorResponsePatch.setStatus(String.valueOf(httpStatus.value())); + errorResponsePatch.setTitle(title); + return new ResponseEntity<>(errorResponsePatch, httpStatus); + } + + private static ResponseEntity provMnSErrorResponseGet(final HttpStatus httpStatus, final String title) { + final String type = PROVMNS_ERROR_TYPE_PER_ERROR_CODE.get(httpStatus); + final ErrorResponseGet errorResponseGet = new ErrorResponseGet(type); + errorResponseGet.setStatus(String.valueOf(httpStatus.value())); + errorResponseGet.setTitle(title); + return new ResponseEntity<>(errorResponseGet, httpStatus); + } + + private static ResponseEntity provMnSErrorResponseDefault(final HttpStatus httpStatus, final String title) { + final String type = PROVMNS_ERROR_TYPE_PER_ERROR_CODE.get(httpStatus); + final ErrorResponseDefault errorResponseDefault = new ErrorResponseDefault(type); + errorResponseDefault.setStatus(String.valueOf(httpStatus.value())); + errorResponseDefault.setTitle(title); + return new ResponseEntity<>(errorResponseDefault, httpStatus); + } + +} diff --git a/cps-ncmp-rest/src/main/java/org/onap/cps/ncmp/rest/controller/ProvMnsController.java b/cps-ncmp-rest/src/main/java/org/onap/cps/ncmp/rest/controller/ProvMnsController.java deleted file mode 100644 index 47a7a73f18..0000000000 --- a/cps-ncmp-rest/src/main/java/org/onap/cps/ncmp/rest/controller/ProvMnsController.java +++ /dev/null @@ -1,219 +0,0 @@ -/* - * ============LICENSE_START======================================================= - * Copyright (C) 2025 OpenInfra Foundation Europe - * ================================================================================ - * 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.ncmp.rest.controller; - -import jakarta.servlet.http.HttpServletRequest; -import java.util.List; -import lombok.RequiredArgsConstructor; -import org.onap.cps.ncmp.api.data.models.OperationType; -import org.onap.cps.ncmp.api.exceptions.ProvMnSException; -import org.onap.cps.ncmp.api.inventory.models.CmHandleState; -import org.onap.cps.ncmp.exceptions.NoAlternateIdMatchFoundException; -import org.onap.cps.ncmp.impl.data.policyexecutor.OperationDetailsFactory; -import org.onap.cps.ncmp.impl.data.policyexecutor.PolicyExecutor; -import org.onap.cps.ncmp.impl.dmi.DmiRestClient; -import org.onap.cps.ncmp.impl.inventory.InventoryPersistence; -import org.onap.cps.ncmp.impl.inventory.models.YangModelCmHandle; -import org.onap.cps.ncmp.impl.models.RequiredDmiService; -import org.onap.cps.ncmp.impl.provmns.ParameterMapper; -import org.onap.cps.ncmp.impl.provmns.ParametersBuilder; -import org.onap.cps.ncmp.impl.provmns.RequestPathParameters; -import org.onap.cps.ncmp.impl.provmns.model.ClassNameIdGetDataNodeSelectorParameter; -import org.onap.cps.ncmp.impl.provmns.model.PatchItem; -import org.onap.cps.ncmp.impl.provmns.model.Resource; -import org.onap.cps.ncmp.impl.provmns.model.Scope; -import org.onap.cps.ncmp.impl.utils.AlternateIdMatcher; -import org.onap.cps.ncmp.impl.utils.http.UrlTemplateParameters; -import org.onap.cps.ncmp.rest.provmns.ErrorResponseBuilder; -import org.onap.cps.utils.JsonObjectMapper; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping("${rest.api.provmns-base-path}") -@RequiredArgsConstructor -public class ProvMnsController implements ProvMnS { - - private static final String NO_AUTHORIZATION = null; - private static final String PROVMNS_NOT_SUPPORTED_ERROR_MESSAGE = - "Registered DMI does not support the ProvMnS interface."; - - private final AlternateIdMatcher alternateIdMatcher; - private final DmiRestClient dmiRestClient; - private final InventoryPersistence inventoryPersistence; - private final ParametersBuilder parametersBuilder; - private final ParameterMapper parameterMapper; - private final ErrorResponseBuilder errorResponseBuilder; - private final PolicyExecutor policyExecutor; - private final JsonObjectMapper jsonObjectMapper; - private final OperationDetailsFactory operationDetailsFactory; - - @Value("${app.ncmp.provmns.max-patch-operations:10}") - private Integer maxNumberOfPatchOperations; - - @Override - public ResponseEntity getMoi(final HttpServletRequest httpServletRequest, - final Scope scope, - final String filter, - final List attributes, - final List fields, - final ClassNameIdGetDataNodeSelectorParameter dataNodeSelector) { - final RequestPathParameters requestPathParameters = - parameterMapper.extractRequestParameters(httpServletRequest); - try { - final YangModelCmHandle yangModelCmHandle = inventoryPersistence.getYangModelCmHandle( - alternateIdMatcher.getCmHandleIdByLongestMatchingAlternateId( - requestPathParameters.toAlternateId(), "/")); - checkTarget(yangModelCmHandle); - final UrlTemplateParameters urlTemplateParameters = parametersBuilder.createUrlTemplateParametersForRead( - scope, filter, attributes, fields, dataNodeSelector, yangModelCmHandle, requestPathParameters); - return dmiRestClient.synchronousGetOperation( - RequiredDmiService.DATA, urlTemplateParameters); - } catch (final NoAlternateIdMatchFoundException noAlternateIdMatchFoundException) { - final String reason = buildNotFoundMessage(requestPathParameters.toAlternateId()); - return errorResponseBuilder.buildErrorResponseGet(HttpStatus.NOT_FOUND, reason); - } catch (final ProvMnSException exception) { - return errorResponseBuilder.buildErrorResponseGet( - getHttpStatusForProvMnSException(exception), exception.getDetails()); - } - } - - @Override - public ResponseEntity patchMoi(final HttpServletRequest httpServletRequest, - final List patchItems) { - if (patchItems.size() > maxNumberOfPatchOperations) { - return errorResponseBuilder.buildErrorResponsePatch(HttpStatus.PAYLOAD_TOO_LARGE, - patchItems.size() + " operations in request, this exceeds the maximum of " - + maxNumberOfPatchOperations); - } - final RequestPathParameters requestPathParameters = - parameterMapper.extractRequestParameters(httpServletRequest); - try { - final YangModelCmHandle yangModelCmHandle = inventoryPersistence.getYangModelCmHandle( - alternateIdMatcher.getCmHandleIdByLongestMatchingAlternateId( - requestPathParameters.toAlternateId(), "/")); - checkTarget(yangModelCmHandle); - operationDetailsFactory - .checkPermissionForEachPatchItem(requestPathParameters, patchItems, yangModelCmHandle); - final UrlTemplateParameters urlTemplateParameters = - parametersBuilder.createUrlTemplateParametersForWrite(yangModelCmHandle, requestPathParameters); - return dmiRestClient.synchronousPatchOperation(RequiredDmiService.DATA, patchItems, - urlTemplateParameters, httpServletRequest.getContentType()); - } catch (final NoAlternateIdMatchFoundException noAlternateIdMatchFoundException) { - final String reason = buildNotFoundMessage(requestPathParameters.toAlternateId()); - return errorResponseBuilder.buildErrorResponsePatch(HttpStatus.NOT_FOUND, reason); - } catch (final ProvMnSException exception) { - return errorResponseBuilder.buildErrorResponsePatch( - getHttpStatusForProvMnSException(exception), exception.getDetails()); - } catch (final RuntimeException exception) { - return errorResponseBuilder.buildErrorResponsePatch(HttpStatus.NOT_ACCEPTABLE, exception.getMessage()); - } - } - - @Override - public ResponseEntity putMoi(final HttpServletRequest httpServletRequest, final Resource resource) { - final RequestPathParameters requestPathParameters = - parameterMapper.extractRequestParameters(httpServletRequest); - try { - final YangModelCmHandle yangModelCmHandle = inventoryPersistence.getYangModelCmHandle( - alternateIdMatcher.getCmHandleIdByLongestMatchingAlternateId( - requestPathParameters.toAlternateId(), "/")); - checkTarget(yangModelCmHandle); - policyExecutor.checkPermission(yangModelCmHandle, - OperationType.CREATE, - NO_AUTHORIZATION, - requestPathParameters.toAlternateId(), - jsonObjectMapper.asJsonString( - operationDetailsFactory.buildCreateOperationDetails(OperationType.CREATE, - requestPathParameters, - resource))); - final UrlTemplateParameters urlTemplateParameters = - parametersBuilder.createUrlTemplateParametersForWrite(yangModelCmHandle, requestPathParameters); - return dmiRestClient.synchronousPutOperation(RequiredDmiService.DATA, resource, urlTemplateParameters); - } catch (final NoAlternateIdMatchFoundException noAlternateIdMatchFoundException) { - final String reason = buildNotFoundMessage(requestPathParameters.toAlternateId()); - return errorResponseBuilder.buildErrorResponseDefault(HttpStatus.NOT_FOUND, reason); - } catch (final ProvMnSException exception) { - return errorResponseBuilder.buildErrorResponseDefault( - getHttpStatusForProvMnSException(exception), exception.getDetails()); - } catch (final RuntimeException exception) { - return errorResponseBuilder.buildErrorResponseDefault(HttpStatus.NOT_ACCEPTABLE, exception.getMessage()); - } - } - - @Override - public ResponseEntity deleteMoi(final HttpServletRequest httpServletRequest) { - final RequestPathParameters requestPathParameters = - parameterMapper.extractRequestParameters(httpServletRequest); - try { - final YangModelCmHandle yangModelCmHandle = inventoryPersistence.getYangModelCmHandle( - alternateIdMatcher.getCmHandleIdByLongestMatchingAlternateId( - requestPathParameters.toAlternateId(), "/")); - checkTarget(yangModelCmHandle); - policyExecutor.checkPermission(yangModelCmHandle, - OperationType.DELETE, - NO_AUTHORIZATION, - requestPathParameters.toAlternateId(), - jsonObjectMapper.asJsonString( - operationDetailsFactory.buildDeleteOperationDetails(requestPathParameters.toAlternateId())) - ); - final UrlTemplateParameters urlTemplateParameters = - parametersBuilder.createUrlTemplateParametersForWrite(yangModelCmHandle, requestPathParameters); - return dmiRestClient.synchronousDeleteOperation(RequiredDmiService.DATA, urlTemplateParameters); - } catch (final NoAlternateIdMatchFoundException noAlternateIdMatchFoundException) { - final String reason = buildNotFoundMessage(requestPathParameters.toAlternateId()); - return errorResponseBuilder.buildErrorResponseDefault(HttpStatus.NOT_FOUND, reason); - } catch (final ProvMnSException exception) { - return errorResponseBuilder.buildErrorResponseDefault( - getHttpStatusForProvMnSException(exception), exception.getDetails()); - } catch (final RuntimeException exception) { - return errorResponseBuilder.buildErrorResponseDefault(HttpStatus.NOT_ACCEPTABLE, - exception.getMessage()); - } - } - - private void checkTarget(final YangModelCmHandle yangModelCmHandle) { - if (yangModelCmHandle.getDataProducerIdentifier() == null - || yangModelCmHandle.getDataProducerIdentifier().isBlank()) { - throw new ProvMnSException("NO DATA PRODUCER ID", PROVMNS_NOT_SUPPORTED_ERROR_MESSAGE); - } else if (yangModelCmHandle.getCompositeState().getCmHandleState() != CmHandleState.READY) { - throw new ProvMnSException("NOT READY", buildNotReadyStateMessage(yangModelCmHandle)); - } - } - - private String buildNotReadyStateMessage(final YangModelCmHandle yangModelCmHandle) { - return yangModelCmHandle.getId() + " is not in ready state. Current state:" - + yangModelCmHandle.getCompositeState().getCmHandleState().name(); - } - - private String buildNotFoundMessage(final String alternateId) { - return alternateId + " not found"; - } - - private HttpStatus getHttpStatusForProvMnSException(final ProvMnSException exception) { - return "NOT READY".equals(exception.getMessage()) - ? HttpStatus.NOT_ACCEPTABLE : HttpStatus.UNPROCESSABLE_ENTITY; - } - -} diff --git a/cps-ncmp-rest/src/main/java/org/onap/cps/ncmp/rest/provmns/ErrorResponseBuilder.java b/cps-ncmp-rest/src/main/java/org/onap/cps/ncmp/rest/provmns/ErrorResponseBuilder.java deleted file mode 100644 index ac0a9e44d7..0000000000 --- a/cps-ncmp-rest/src/main/java/org/onap/cps/ncmp/rest/provmns/ErrorResponseBuilder.java +++ /dev/null @@ -1,83 +0,0 @@ -/* - * ============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.ncmp.rest.provmns; - -import java.util.Map; -import org.onap.cps.ncmp.impl.provmns.model.ErrorResponseDefault; -import org.onap.cps.ncmp.impl.provmns.model.ErrorResponseGet; -import org.onap.cps.ncmp.impl.provmns.model.ErrorResponsePatch; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Component; - -@Component -public class ErrorResponseBuilder { - - private static final Map ERROR_MAP = Map.of( - HttpStatus.NOT_FOUND, "IE_NOT_FOUND", - HttpStatus.NOT_ACCEPTABLE, "APPLICATION_LAYER_ERROR", - HttpStatus.UNPROCESSABLE_ENTITY, "SERVER_LIMITATION", - HttpStatus.PAYLOAD_TOO_LARGE, "SERVER_LIMITATION" - ); - - /** - * Create response entity for default error response. - * Default is used by PUT and DELETE - * - * @param httpStatus HTTP response - * @param reason reason for error response - * @return response entity - */ - public ResponseEntity buildErrorResponseDefault(final HttpStatus httpStatus, final String reason) { - final ErrorResponseDefault errorResponseDefault = new ErrorResponseDefault(ERROR_MAP.get(httpStatus)); - errorResponseDefault.setStatus(httpStatus.toString()); - errorResponseDefault.setReason(reason); - return new ResponseEntity<>(errorResponseDefault, httpStatus); - } - - /** - * Create response entity for get error response. - * - * @param httpStatus HTTP response - * @param reason reason for error response - * @return response entity - */ - public ResponseEntity buildErrorResponseGet(final HttpStatus httpStatus, final String reason) { - final ErrorResponseGet errorResponseGet = new ErrorResponseGet(ERROR_MAP.get(httpStatus)); - errorResponseGet.setStatus(httpStatus.toString()); - errorResponseGet.setReason(reason); - return new ResponseEntity<>(errorResponseGet, httpStatus); - } - - /** - * Create response entity for patch error response. - * - * @param httpStatus HTTP response - * @param reason reason for error response - * @return response entity - */ - public ResponseEntity buildErrorResponsePatch(final HttpStatus httpStatus, final String reason) { - final ErrorResponsePatch errorResponsePatch = new ErrorResponsePatch(ERROR_MAP.get(httpStatus)); - errorResponsePatch.setStatus(httpStatus.toString()); - errorResponsePatch.setReason(reason); - return new ResponseEntity<>(errorResponsePatch, httpStatus); - } -} diff --git a/cps-ncmp-rest/src/test/groovy/org/onap/cps/ncmp/rest/controller/NetworkCmProxyRestExceptionHandlerSpec.groovy b/cps-ncmp-rest/src/test/groovy/org/onap/cps/ncmp/rest/controller/NetworkCmProxyRestExceptionHandlerSpec.groovy index ac193b782c..e97c586561 100644 --- a/cps-ncmp-rest/src/test/groovy/org/onap/cps/ncmp/rest/controller/NetworkCmProxyRestExceptionHandlerSpec.groovy +++ b/cps-ncmp-rest/src/test/groovy/org/onap/cps/ncmp/rest/controller/NetworkCmProxyRestExceptionHandlerSpec.groovy @@ -24,6 +24,10 @@ package org.onap.cps.ncmp.rest.controller import groovy.json.JsonSlurper import org.mapstruct.factory.Mappers import org.onap.cps.TestUtils +import org.onap.cps.api.exceptions.AlreadyDefinedException +import org.onap.cps.api.exceptions.CpsException +import org.onap.cps.api.exceptions.DataNodeNotFoundException +import org.onap.cps.api.exceptions.DataValidationException import org.onap.cps.ncmp.api.data.exceptions.InvalidOperationException import org.onap.cps.ncmp.api.data.exceptions.OperationNotSupportedException import org.onap.cps.ncmp.api.exceptions.DmiClientRequestException @@ -38,17 +42,12 @@ import org.onap.cps.ncmp.impl.data.NetworkCmProxyFacade import org.onap.cps.ncmp.impl.data.policyexecutor.PolicyExecutor import org.onap.cps.ncmp.impl.dmi.DmiRestClient import org.onap.cps.ncmp.impl.inventory.InventoryPersistence +import org.onap.cps.ncmp.impl.provmns.ParametersBuilder import org.onap.cps.ncmp.impl.utils.AlternateIdMatcher -import org.onap.cps.ncmp.api.exceptions.ProvMnSException import org.onap.cps.ncmp.rest.util.CmHandleStateMapper import org.onap.cps.ncmp.rest.util.DataOperationRequestMapper import org.onap.cps.ncmp.rest.util.DeprecationHelper import org.onap.cps.ncmp.rest.util.NcmpRestInputMapper -import org.onap.cps.api.exceptions.AlreadyDefinedException -import org.onap.cps.api.exceptions.CpsException -import org.onap.cps.api.exceptions.DataNodeNotFoundException -import org.onap.cps.api.exceptions.DataValidationException -import org.onap.cps.ncmp.impl.provmns.ParametersBuilder import org.onap.cps.ncmp.rest.util.RestOutputCmHandleMapper import org.onap.cps.utils.JsonObjectMapper import org.spockframework.spring.SpringBean @@ -69,7 +68,6 @@ import static org.springframework.http.HttpStatus.CONFLICT import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR import static org.springframework.http.HttpStatus.NOT_FOUND import static org.springframework.http.HttpStatus.PAYLOAD_TOO_LARGE -import static org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post @@ -119,7 +117,7 @@ class NetworkCmProxyRestExceptionHandlerSpec extends Specification { ParametersBuilder mockProvMnSParametersMapper = Mock() @SpringBean - ProvMnsController mockProvMnsController = Mock() + ProvMnSController mockProvMnsController = Mock() @SpringBean AlternateIdMatcher alternateIdMatcher = Mock() @@ -169,7 +167,6 @@ class NetworkCmProxyRestExceptionHandlerSpec extends Specification { 'Existing entries' | AlreadyDefinedException.forDataNodes(['A', 'B'], 'myAnchorName') || CONFLICT | 'Already defined exception' | '2 data node(s) already exist' 'Operation too large' | new PayloadTooLargeException(sampleErrorMessage) || PAYLOAD_TOO_LARGE | sampleErrorMessage | 'Check logs' 'Policy Executor' | new PolicyExecutorException(sampleErrorMessage, sampleErrorDetails, null) || CONFLICT | sampleErrorMessage | sampleErrorDetails - 'Invalid Path' | new ProvMnSException('not a valid path' ,'some details') || UNPROCESSABLE_ENTITY | 'not a valid path' | 'some details' } def 'Post request with exception returns correct HTTP Status.'() { @@ -188,7 +185,7 @@ class NetworkCmProxyRestExceptionHandlerSpec extends Specification { when: 'the DMI request is executed' def response = performTestRequest(NCMP) then: 'NCMP service responds with 502 Bad Gateway status' - response.status == BAD_GATEWAY.value() + assert response.status == BAD_GATEWAY.value() and: 'the NCMP response also contains the original DMI response details' response.contentAsString.contains('400') response.contentAsString.contains('Bad Request from DMI') diff --git a/cps-ncmp-rest/src/test/groovy/org/onap/cps/ncmp/rest/controller/ProvMnSControllerSpec.groovy b/cps-ncmp-rest/src/test/groovy/org/onap/cps/ncmp/rest/controller/ProvMnSControllerSpec.groovy new file mode 100644 index 0000000000..a876191e62 --- /dev/null +++ b/cps-ncmp-rest/src/test/groovy/org/onap/cps/ncmp/rest/controller/ProvMnSControllerSpec.groovy @@ -0,0 +1,392 @@ +/* + * ============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.ncmp.rest.controller + +import com.fasterxml.jackson.databind.ObjectMapper +import io.netty.handler.timeout.TimeoutException +import org.onap.cps.ncmp.api.exceptions.PolicyExecutorException +import org.onap.cps.ncmp.api.inventory.models.CompositeState +import org.onap.cps.ncmp.exceptions.NoAlternateIdMatchFoundException +import org.onap.cps.ncmp.impl.data.policyexecutor.OperationDetailsFactory +import org.onap.cps.ncmp.impl.data.policyexecutor.PolicyExecutor +import org.onap.cps.ncmp.impl.dmi.DmiRestClient +import org.onap.cps.ncmp.impl.inventory.InventoryPersistence +import org.onap.cps.ncmp.impl.inventory.models.YangModelCmHandle +import org.onap.cps.ncmp.impl.provmns.ParameterMapper +import org.onap.cps.ncmp.impl.provmns.ParametersBuilder +import org.onap.cps.ncmp.impl.provmns.model.PatchItem +import org.onap.cps.ncmp.impl.utils.AlternateIdMatcher +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 +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.test.web.servlet.MockMvc +import spock.lang.Specification + +import static org.onap.cps.ncmp.api.inventory.models.CmHandleState.ADVISED +import static org.onap.cps.ncmp.api.inventory.models.CmHandleState.READY +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.patch +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put + +@WebMvcTest([ProvMnSController, OperationDetailsFactory]) +class ProvMnSControllerSpec extends Specification { + + @SpringBean + ParametersBuilder parametersBuilder = new ParametersBuilder() + + @SpringBean + AlternateIdMatcher mockAlternateIdMatcher = Mock() + + @SpringBean + InventoryPersistence mockInventoryPersistence = Mock() + + @SpringBean + DmiRestClient mockDmiRestClient = Mock() + + @Autowired + OperationDetailsFactory operationDetailsFactory + + @SpringBean + ParameterMapper parameterMapper = new ParameterMapper() + + @SpringBean + PolicyExecutor mockPolicyExecutor = Mock() + + @Autowired + MockMvc mvc + + @SpringBean + ObjectMapper objectMapper = new ObjectMapper() + + @SpringBean + JsonObjectMapper jsonObjectMapper = new JsonObjectMapper(objectMapper) + + static def resourceAsJson = '{"id":"test"}' + static def validCmHandle = new YangModelCmHandle(id:'ch-1', dmiServiceName: 'someDmiService', dataProducerIdentifier: 'someDataProducerId', compositeState: new CompositeState(cmHandleState: READY)) + static def cmHandleWithoutDataProducer = new YangModelCmHandle(id:'ch-1', dmiServiceName: 'someDmiService', compositeState: new CompositeState(cmHandleState: READY)) + static def cmHandleNotReady = new YangModelCmHandle(id:'ch-1', dmiServiceName: 'someDmiService', dataProducerIdentifier: 'someDataProducerId', compositeState: new CompositeState(cmHandleState: ADVISED)) + + static def patchMediaType = new MediaType('application', 'json-patch+json') + static def patchMediaType3gpp = new MediaType('application', '3gpp-json-patch+json') + static def patchJsonBody = '[{"op":"replace","path":"className/attributes/attr1","value":{"id":"test"}}]' + static def patchJsonBody3gpp = '[{"op":"replace","path":"className#/attributes/attr1","value":{"id":"test"}}]' + static def patchJsonBodyInvalid = '[{"op":"replace","path":"/test","value":"INVALID"}]' + + @Value('${rest.api.provmns-base-path}') + def provMnSBasePath + + @Value('${app.ncmp.provmns.max-patch-operations:10}') + int maxNumberOfPatchOperations + + static def NO_CONTENT = '' + static def STATUS_NOT_RELEVANT = HttpStatus.SEE_OTHER + + def 'Get resource data #scenario.'() { + given: 'resource data url' + def getUrl = "$provMnSBasePath/v1/myClass=id1?otherQueryParameter=ignored" + and: 'alternate Id can be matched' + mockAlternateIdMatcher.getCmHandleIdByLongestMatchingAlternateId('/myClass=id1', "/") >> 'ch-1' + and: 'persistence service returns yangModelCmHandle' + mockInventoryPersistence.getYangModelCmHandle('ch-1') >> validCmHandle + and: 'dmi provides a response' + mockDmiRestClient.synchronousGetOperation(*_) >> new ResponseEntity<>(responseContentFromDmi, responseStatusFromDmi) + when: 'get data resource request is performed' + def response = mvc.perform(get(getUrl)).andReturn().response + then: 'response status is the same as what DMI gave' + assert response.status == responseStatusFromDmi.value() + and: 'the content is whatever the DMI returned' + assert response.contentAsString == responseContentFromDmi + where: 'following responses returned by DMI' + scenario | responseStatusFromDmi | responseContentFromDmi + 'happy flow' | HttpStatus.OK | 'content from DMI' + 'error from DMI' | HttpStatus.I_AM_A_TEAPOT | 'error details from DMI' + } + + def 'Get resource data request with #scenario.'() { + given: 'resource data url' + def getUrl = "$provMnSBasePath/v1/myClass=id1?otherQueryParameter=ignored" + and: 'alternate Id can be matched' + mockAlternateIdMatcher.getCmHandleIdByLongestMatchingAlternateId('/myClass=id1', "/") >> 'ch-1' + and: 'persistence service returns yangModelCmHandle' + mockInventoryPersistence.getYangModelCmHandle('ch-1') >> yangModelCmHandle + and: 'dmi provides a response' + mockDmiRestClient.synchronousGetOperation(*_) >> new ResponseEntity<>('Response from DMI service', HttpStatus.OK) + when: 'get data resource request is performed' + def response = mvc.perform(get(getUrl)).andReturn().response + then: 'response status as expected' + assert response.status == expectedHttpStatus.value() + and: 'the body contains the expected data' + assert response.contentAsString.contains(expectedInResponseBody) + where: 'following scenario' + scenario | yangModelCmHandle || expectedHttpStatus | expectedInResponseBody + 'no data producer id' | cmHandleWithoutDataProducer || HttpStatus.UNPROCESSABLE_ENTITY | '"title":"Registered DMI does not support the ProvMnS interface."' + 'cm Handle NOT READY' | cmHandleNotReady || HttpStatus.NOT_ACCEPTABLE | '"title":"ch-1 is not in READY state. Current state: ADVISED"' + } + + def 'Get resource data request with Exception: #exceptionDuringProcessing.'() { + given: 'resource data url' + def getUrl = "$provMnSBasePath/v1/myClass=id1" + and: 'an exception happens during the process (method not relevant)' + mockAlternateIdMatcher.getCmHandleIdByLongestMatchingAlternateId(*_) >> {throw exceptionDuringProcessing} + when: 'get data resource request is performed' + def response = mvc.perform(get(getUrl)).andReturn().response + then: 'response status is #expectedHttpStatus' + assert response.status == expectedHttpStatus.value() + and: 'the title in the response indicates the fdn that cannot be found' + assert response.contentAsString.contains(expectedContent) + where: 'following exceptions occur' + exceptionDuringProcessing || expectedHttpStatus || expectedContent + new NoAlternateIdMatchFoundException('myTarget') || HttpStatus.NOT_FOUND || '"title":"/myClass=id1 not found"' + new Exception("my message", new TimeoutException()) || HttpStatus.GATEWAY_TIMEOUT || '"title":"my message"' + new Throwable("my message") || HttpStatus.INTERNAL_SERVER_ERROR || '"title":"my message"' + } + + + def 'Get resource data request with invalid URL: #scenario.'() { + given: 'resource data url' + def getUrl = "$provMnSBasePath/$version/$fdn$queryParameter" + when: 'get data resource request is performed' + def response = mvc.perform(get(getUrl)).andReturn().response + then: 'response status as expected' + assert response.status == expectedHttpStatus + and: 'the body contains the expected 3GPP error data (when applicable)' + assert response.contentAsString.contains(expectedType) + where: 'following scenario' + scenario | version | fdn | queryParameter || expectedHttpStatus || expectedType | expectedTitle + 'invalid version' | 'v0' | 'not relevant' | 'not relevant' || 404 || NO_CONTENT | NO_CONTENT + 'no fdn' | 'v1' | '' | '' || 422 || '"type":"SERVER_LIMITATION"' | '"title":"/ProvMnS/v1/ not a valid path"' + 'fdn without class name' | 'v1' | 'someClass' | '' || 422 || '"type":"SERVER_LIMITATION"' | '"title":"/ProvMnS/v1/segment not a valid path"' + 'incorrect scope(type)' | 'v1' | 'someClass=1' | '?scopeType=WRONG' || 400 || NO_CONTENT | NO_CONTENT + } + + def 'Get resource data request with list parameter: #scenario.'() { + given: 'resource data url' + def getUrl = "$provMnSBasePath/v1/myClass=id1$parameterInProvMnsRequest" + and: 'alternate Id can be matched' + mockAlternateIdMatcher.getCmHandleIdByLongestMatchingAlternateId('/myClass=id1', "/") >> 'ch-1' + and: 'persistence service returns yangModelCmHandle' + mockInventoryPersistence.getYangModelCmHandle('ch-1') >> validCmHandle + when: 'get data resource request is performed' + mvc.perform(get(getUrl)) + then: 'the request to dmi contains the expected url parameters and values and then returns OK' + 1 * mockDmiRestClient.synchronousGetOperation(*_) >> {arguments -> def urlTemplateParameters = arguments[1] + assert urlTemplateParameters.urlTemplate.contains(expectedParameterInUri) + assert urlTemplateParameters.urlVariables().get(parameterName) == expectedParameterValuesInDmiRequest + return new ResponseEntity<>('Some response from DMI service', HttpStatus.OK) + } + where: 'attributes is populated with the following ' + scenario | parameterName | parameterInProvMnsRequest || expectedParameterInUri || expectedParameterValuesInDmiRequest + 'some attributes' | 'attributes' | '?attributes=value1,value2' || '?attributes={attributes}' || 'value1,value2' + 'empty attributes' | 'attributes' | '?attributes=' || '?attributes={attributes}' || '' + 'no attributes (null)' | 'attributes' | '' || '' || null + 'some fields' | 'fields' | '?fields=value3,value4' || '?fields={fields}' || 'value3,value4' + 'empty fields' | 'fields' | '?fields=' || '?fields={fields}' || '' + 'no fields (null)' | 'fields' | '' || '' || null + } + + def 'Patch request with #scenario.'() { + given: 'provmns url' + def provmnsUrl = "$provMnSBasePath/v1/myClass=id1" + and: 'alternate Id can be matched' + mockAlternateIdMatcher.getCmHandleIdByLongestMatchingAlternateId('/myClass=id1', "/") >> 'ch-1' + and: 'persistence service returns yangModelCmHandle' + mockInventoryPersistence.getYangModelCmHandle('ch-1') >> validCmHandle + and: 'dmi provides a response' + mockDmiRestClient.synchronousPatchOperation(*_) >> new ResponseEntity<>('content from DMI', responseStatusFromDmi) + when: 'patch request is performed' + def response = mvc.perform(patch(provmnsUrl) + .contentType(contentMediaType) + .content(jsonBody)) + .andReturn().response + then: 'response status is the same as what DMI gave' + assert response.status == expectedRespinsStatusFromProvMnS.value() + and: 'the response contains the expected content' + assert response.contentAsString.contains(expectedResponseContent) + where: 'following scenarios are applied' + scenario | contentMediaType | jsonBody | responseStatusFromDmi | expectedResponseContent || expectedRespinsStatusFromProvMnS + 'happy flow 3gpp' | patchMediaType3gpp | patchJsonBody3gpp | HttpStatus.OK | 'content from DMI' || HttpStatus.OK + 'happy flow' | patchMediaType | patchJsonBody | HttpStatus.OK | 'content from DMI' || HttpStatus.OK + 'error from DMI' | patchMediaType | patchJsonBody | HttpStatus.I_AM_A_TEAPOT | 'content from DMI' || HttpStatus.I_AM_A_TEAPOT + 'invalid Json' | patchMediaType | patchJsonBodyInvalid | STATUS_NOT_RELEVANT | '"title":"Parsing error' || HttpStatus.BAD_REQUEST + 'malformed Json' | patchMediaType | '{malformed]' | STATUS_NOT_RELEVANT | NO_CONTENT || HttpStatus.BAD_REQUEST + } + + def 'Patch request with no permission from Coordination Management (aka Policy Executor).'() { + given: 'resource data url' + def url = "$provMnSBasePath/v1/myClass=id1" + and: 'alternate Id can be matched' + mockAlternateIdMatcher.getCmHandleIdByLongestMatchingAlternateId('/myClass=id1', "/") >> 'ch-1' + and: 'persistence service returns valid yangModelCmHandle' + mockInventoryPersistence.getYangModelCmHandle('ch-1') >> validCmHandle + and: 'the permission is denied (Policy Executor throws an exception)' + mockPolicyExecutor.checkPermission(*_) >> {throw new PolicyExecutorException('denied for test','details',null)} + when: 'patch data resource request is performed' + def response = mvc.perform(patch(url) + .contentType(patchMediaType) + .content(patchJsonBody)) + .andReturn().response + then: 'response status is CONFLICT (409)' + assert response.status == HttpStatus.CONFLICT.value() + and: 'response contains the message from Policy Executor (as title)' + assert response.contentAsString.contains('"title":"denied for test"') + } + + def 'Patch request with incorrect Media Types #scenario.'() { + given: 'resource data url' + def url = "$provMnSBasePath/v1/someClass=someId" + when: 'patch data resource request is performed' + def response = mvc.perform(patch(url) + .contentType(contentType) + .accept(acceptType) + .content(patchJsonBody)) + .andReturn().response + then: 'response status is #expectedHttpStatus' + assert response.status == expectedHttpStatus.value() + where: 'following media types are used' + scenario | contentType | acceptType || expectedHttpStatus + 'Content Type Wrong' | MediaType.TEXT_XML | MediaType.APPLICATION_JSON || HttpStatus.UNSUPPORTED_MEDIA_TYPE + 'Accept Type Wrong' | patchMediaType | MediaType.TEXT_XML || HttpStatus.NOT_ACCEPTABLE + } + + def 'Patch request with too many operations.'() { + given: 'resource data url' + def url = "$provMnSBasePath/v1/someClass=someId" + and: 'a patch request with more operations than the max allowed' + def patchItems = [] + for (def i = 0; i <= maxNumberOfPatchOperations; i++) { + patchItems.add(new PatchItem(op: 'REMOVE', path: 'somePath')) + } + def patchItemsJsonRequestBody = jsonObjectMapper.asJsonString(patchItems) + when: 'patch data resource request is performed' + def response = mvc.perform(patch(url) + .contentType(patchMediaType) + .content(patchItemsJsonRequestBody)) + .andReturn().response + then: 'response status is PAYLOAD_TOO_LARGE (413)' + assert response.status == HttpStatus.PAYLOAD_TOO_LARGE.value() + and: 'response contains a title detail the limitations with the number of operations' + assert response.contentAsString.contains('"title":"11 operations in request, this exceeds the maximum of 10"') + } + + def 'Put resource data request with #scenario.'() { + given: 'resource data url' + def putUrl = "$provMnSBasePath/v1/myClass=id1" + and: 'alternate Id can be matched' + mockAlternateIdMatcher.getCmHandleIdByLongestMatchingAlternateId('/myClass=id1', "/") >> 'ch-1' + and: 'persistence service returns yangModelCmHandle' + mockInventoryPersistence.getYangModelCmHandle('ch-1') >> validCmHandle + and: 'dmi provides a response' + mockDmiRestClient.synchronousPutOperation(*_) >> new ResponseEntity<>(responseContentFromDmi, responseStatusFromDmi) + when: 'put data resource request is performed' + def response = mvc.perform(put(putUrl) + .contentType(MediaType.APPLICATION_JSON) + .content(resourceAsJson)) + .andReturn().response + then: 'response status is the same as what DMI gave' + assert response.status == responseStatusFromDmi.value() + and: 'the content is whatever the DMI returned' + assert response.contentAsString == responseContentFromDmi + where: 'following responses returned by DMI' + scenario | responseStatusFromDmi | responseContentFromDmi + 'happy flow' | HttpStatus.OK | 'content from DMI' + 'error from DMI' | HttpStatus.I_AM_A_TEAPOT | 'error details from DMI' + } + + def 'Put resource data request when cm handle not found.'() { + given: 'resource data url' + def putUrl = "$provMnSBasePath/v1/myClass=id1" + and: 'cannot match alternate ID' + mockAlternateIdMatcher.getCmHandleIdByLongestMatchingAlternateId(*_) >> { throw new NoAlternateIdMatchFoundException('') } + when: 'put data resource request is performed' + def response = mvc.perform(put(putUrl) + .contentType(MediaType.APPLICATION_JSON) + .content(resourceAsJson)) + .andReturn().response + then: 'response status NOT FOUND (404)' + assert response.status == HttpStatus.NOT_FOUND.value() + and: 'the content indicates the FDN could not be found' + assert response.contentAsString.contains('"title":"/myClass=id1 not found"') + } + + def 'Delete resource data request with #scenario.'() { + given: 'resource data url' + def deleteUrl = "$provMnSBasePath/v1/myClass=id1" + and: 'alternate Id can be matched' + mockAlternateIdMatcher.getCmHandleIdByLongestMatchingAlternateId('/myClass=id1', "/") >> 'ch-1' + and: 'persistence service returns yangModelCmHandle' + mockInventoryPersistence.getYangModelCmHandle('ch-1') >> validCmHandle + and: 'dmi provides a response' + mockDmiRestClient.synchronousDeleteOperation(*_) >> new ResponseEntity<>(responseContentFromDmi, responseStatusFromDmi) + when: 'Delete data resource request is performed' + def response = mvc.perform(delete(deleteUrl)).andReturn().response + then: 'response status is the same as what DMI gave' + assert response.status == responseStatusFromDmi.value() + and: 'the content is whatever the DMI returned' + assert response.contentAsString == responseContentFromDmi + where: 'following responses returned by DMI' + scenario | responseStatusFromDmi | responseContentFromDmi + 'happy flow' | HttpStatus.OK | 'content from DMI' + 'error from DMI' | HttpStatus.I_AM_A_TEAPOT | 'error details from DMI' + } + + def 'Delete resource data request when cm handle not found.'() { + given: 'resource data url' + def deleteUrl = "$provMnSBasePath/v1/myClass=id1" + and: 'cannot match alternate ID' + mockAlternateIdMatcher.getCmHandleIdByLongestMatchingAlternateId(*_) >> { throw new NoAlternateIdMatchFoundException('') } + when: 'Delete data resource request is performed' + def response = mvc.perform(delete(deleteUrl)).andReturn().response + then: 'response status is the same as what DMI gave' + assert response.status == HttpStatus.NOT_FOUND.value() + and: 'the content indicates the FDN could not be found' + assert response.contentAsString.contains('"title":"/myClass=id1 not found"') + } + + def 'Patch request with invalid attribute reference: #scenario.'() { + given: 'provMns url' + def provMnsUrl = "$provMnSBasePath/v1/myClass=id1" + and: 'alternate Id can be matched' + mockAlternateIdMatcher.getCmHandleIdByLongestMatchingAlternateId('/myClass=id1', "/") >> 'ch-1' + and: 'persistence service returns yangModelCmHandle' + mockInventoryPersistence.getYangModelCmHandle('ch-1') >> new YangModelCmHandle(id:'ch-1', dmiServiceName: 'someDmiService', dataProducerIdentifier: 'someDataProducerId', compositeState: new CompositeState(cmHandleState: READY)) + and: 'dmi provides a response' + mockDmiRestClient.synchronousPatchOperation(*_) >> new ResponseEntity<>('Response from DMI service', HttpStatus.OK) + when: 'patch request is performed' + def response = mvc.perform(patch(provMnsUrl) + .contentType(contentType) + .content('[{"op":"replace","path":"className' + attributeReference + '"}]')) + .andReturn().response + then: 'response status is BAD REQUEST (400)' + assert response.status == HttpStatus.BAD_REQUEST.value() + and: 'response contains expected message for error case' + assert response.contentAsString.contains('"title":"Invalid path for content-type ' + contentType + '"') + where: 'following scenarios' + scenario | contentType | attributeReference + '3gpp without hash' | 'application/3gpp-json-patch+json' | '/attributes/attr1' + 'standard with hash' | "application/json-patch+json" | '#/attributes/attr1' + } +} diff --git a/cps-ncmp-rest/src/test/groovy/org/onap/cps/ncmp/rest/controller/ProvMnsControllerSpec.groovy b/cps-ncmp-rest/src/test/groovy/org/onap/cps/ncmp/rest/controller/ProvMnsControllerSpec.groovy deleted file mode 100644 index b028aa8fc7..0000000000 --- a/cps-ncmp-rest/src/test/groovy/org/onap/cps/ncmp/rest/controller/ProvMnsControllerSpec.groovy +++ /dev/null @@ -1,352 +0,0 @@ -/* - * ============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.ncmp.rest.controller - -import com.fasterxml.jackson.databind.ObjectMapper -import jakarta.servlet.ServletException -import org.onap.cps.ncmp.api.inventory.models.CompositeState -import org.onap.cps.ncmp.exceptions.NoAlternateIdMatchFoundException -import org.onap.cps.ncmp.impl.data.policyexecutor.OperationDetailsFactory -import org.onap.cps.ncmp.impl.data.policyexecutor.PolicyExecutor -import org.onap.cps.ncmp.impl.dmi.DmiRestClient -import org.onap.cps.ncmp.impl.inventory.InventoryPersistence -import org.onap.cps.ncmp.impl.inventory.models.YangModelCmHandle -import org.onap.cps.ncmp.impl.provmns.model.PatchItem -import org.onap.cps.ncmp.impl.provmns.model.ResourceOneOf -import org.onap.cps.ncmp.impl.utils.AlternateIdMatcher -import org.onap.cps.ncmp.rest.provmns.ErrorResponseBuilder -import org.onap.cps.ncmp.impl.provmns.ParametersBuilder -import org.onap.cps.ncmp.impl.provmns.ParameterMapper -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 -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest -import org.springframework.http.HttpStatus -import org.springframework.http.MediaType -import org.springframework.http.ResponseEntity -import org.springframework.test.web.servlet.MockMvc -import spock.lang.Specification - -import static org.onap.cps.ncmp.api.inventory.models.CmHandleState.READY -import static org.onap.cps.ncmp.api.inventory.models.CmHandleState.ADVISED -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.patch -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put - -@WebMvcTest(ProvMnsController) -class ProvMnsControllerSpec extends Specification { - - @SpringBean - ParametersBuilder parametersBuilder = new ParametersBuilder() - - @SpringBean - AlternateIdMatcher alternateIdMatcher = Mock() - - @SpringBean - InventoryPersistence inventoryPersistence = Mock() - - @SpringBean - DmiRestClient dmiRestClient = Mock() - - @SpringBean - OperationDetailsFactory operationDetailsFactory = Mock() - - @SpringBean - ErrorResponseBuilder errorResponseBuilder = new ErrorResponseBuilder() - - @SpringBean - ParameterMapper parameterMapper = new ParameterMapper() - - @SpringBean - PolicyExecutor policyExecutor = Mock() - - @Autowired - MockMvc mvc - - @SpringBean - JsonObjectMapper jsonObjectMapper = new JsonObjectMapper(new ObjectMapper()) - - def jsonBody = jsonObjectMapper.asJsonString(new ResourceOneOf('test')) - def patchJsonBody = jsonObjectMapper.asJsonString([new PatchItem(op: 'REMOVE', path: 'someUriLdnFirstPart')]) - - @Value('${rest.api.provmns-base-path}') - def provMnSBasePath - - @Value('${app.ncmp.provmns.max-patch-operations:10}') - Integer maxNumberOfPatchOperations - - def 'Get resource data request where #scenario.'() { - given: 'resource data url' - def getUrl = "$provMnSBasePath/v1/someUriLdnFirstPart/someClassName=someId" - and: 'alternate Id can be matched' - alternateIdMatcher.getCmHandleIdByLongestMatchingAlternateId('/someUriLdnFirstPart/someClassName=someId', "/") >> 'cm-1' - and: 'persistence service returns yangModelCmHandle' - inventoryPersistence.getYangModelCmHandle('cm-1') >> new YangModelCmHandle(id:'cm-1', dmiServiceName: 'someDmiService', dataProducerIdentifier: dataProducerId, compositeState: new CompositeState(cmHandleState: state)) - and: 'dmi provides a response' - dmiRestClient.synchronousGetOperation(*_) >> new ResponseEntity<>('Some response from DMI service', HttpStatus.OK) - when: 'get data resource request is performed' - def response = mvc.perform(get(getUrl).contentType(MediaType.APPLICATION_JSON)).andReturn().response - then: 'response status as expected' - assert response.status == expectedHttpStatus.value() - where: - scenario | dataProducerId | state || expectedHttpStatus - 'cmHandle state is Ready with populated dataProducerId' | 'someDataProducerId' | READY || HttpStatus.OK - 'dataProducerId is blank' | ' ' | READY || HttpStatus.UNPROCESSABLE_ENTITY - 'dataProducerId is null' | null | READY || HttpStatus.UNPROCESSABLE_ENTITY - 'cmHandle state is Advised' | 'someDataProducerId' | ADVISED || HttpStatus.NOT_ACCEPTABLE - } - - def 'Get resource data request with list parameter: #scenario.'() { - given: 'resource data url' - def getUrl = "$provMnSBasePath/v1/someUriLdnFirstPart/someClassName=someId$parameterInProvMnsRequest" - and: 'alternate Id can be matched' - alternateIdMatcher.getCmHandleIdByLongestMatchingAlternateId('/someUriLdnFirstPart/someClassName=someId', "/") >> 'cm-1' - and: 'persistence service returns yangModelCmHandle' - inventoryPersistence.getYangModelCmHandle('cm-1') >> new YangModelCmHandle(id:'cm-1', dmiServiceName: 'someDmiService', dataProducerIdentifier: 'someDataProducerId', compositeState: new CompositeState(cmHandleState: READY)) - when: 'get data resource request is performed' - mvc.perform(get(getUrl).contentType(MediaType.APPLICATION_JSON)) - then: 'the request to dmi contains the expected url parameters and values and then returns OK' - 1 * dmiRestClient.synchronousGetOperation(*_) >> { arguments -> def urlTemplateParameters = arguments[1] - assert urlTemplateParameters.urlTemplate.contains(expectedParameterInUri) - assert urlTemplateParameters.urlVariables().get(parameterName) == expectedParameterValuesInDmiRequest - return new ResponseEntity<>('Some response from DMI service', HttpStatus.OK) - } - where: 'attributes is populated with the following ' - scenario | parameterName | parameterInProvMnsRequest || expectedParameterInUri || expectedParameterValuesInDmiRequest - 'some attributes' | 'attributes' | '?attributes=value1,value2' || '?attributes={attributes}' || 'value1,value2' - 'empty attributes' | 'attributes' | '?attributes=' || '?attributes={attributes}' || '' - 'no attributes (null)' | 'attributes' | '' || '' || null - 'some fields' | 'fields' | '?fields=value3,value4' || '?fields={fields}' || 'value3,value4' - 'empty fields' | 'fields' | '?fields=' || '?fields={fields}' || '' - 'no fields (null)' | 'fields' | '' || '' || null - } - - def 'Get resource data request with no match for alternate id.'() { - given: 'resource data url' - def getUrl = "$provMnSBasePath/v1/someUriLdnFirstPart/someClassName=someId" - and: 'alternate Id cannot be matched' - alternateIdMatcher.getCmHandleIdByLongestMatchingAlternateId(*_) >> {throw new NoAlternateIdMatchFoundException('someUriLdnFirstPart/someClassName=someId')} - and: 'persistence service returns yangModelCmHandle' - inventoryPersistence.getYangModelCmHandle('cm-1') >> new YangModelCmHandle(id:'cm-1', dmiServiceName: 'someDmiService', dataProducerIdentifier: 'someDataProducerId', compositeState: new CompositeState(cmHandleState: ADVISED)) - when: 'get data resource request is performed' - def response = mvc.perform(get(getUrl).contentType(MediaType.APPLICATION_JSON)).andReturn().response - then: 'response status is NOT_FOUND (404)' - assert response.status == HttpStatus.NOT_FOUND.value() - } - - def 'Patch request where #scenario.'() { - given: 'provmns url' - def provmnsUrl = "$provMnSBasePath/v1/someUriLdnFirstPart/someClassName=someId" - and: 'alternate Id can be matched' - alternateIdMatcher.getCmHandleIdByLongestMatchingAlternateId('/someUriLdnFirstPart/someClassName=someId', "/") >> 'cm-1' - and: 'persistence service returns yangModelCmHandle' - inventoryPersistence.getYangModelCmHandle('cm-1') >> new YangModelCmHandle(id:'cm-1', dmiServiceName: 'someDmiService', dataProducerIdentifier: dataProducerId, compositeState: new CompositeState(cmHandleState: state)) - and: 'dmi provides a response' - dmiRestClient.synchronousPatchOperation(*_) >> new ResponseEntity<>('Some response from DMI service', HttpStatus.OK) - when: 'patch request is performed' - def response = mvc.perform(patch(provmnsUrl) - .contentType(new MediaType('application', 'json-patch+json')) - .content(patchJsonBody)) - .andReturn().response - then: 'response status is as expected' - assert response.status == expectedHttpStatus.value() - where: - scenario | dataProducerId | state || expectedHttpStatus - 'valid request is made' | 'someDataProducerId' | READY || HttpStatus.OK - 'dataProducerId is blank' | ' ' | READY || HttpStatus.UNPROCESSABLE_ENTITY - 'dataProducerId is null' | null | READY || HttpStatus.UNPROCESSABLE_ENTITY - 'cmHandle state is Advised' | 'someDataProducerId' | ADVISED || HttpStatus.NOT_ACCEPTABLE - } - - def 'Patch request with no match for alternate id.'() { - given: 'resource data url' - def provmnsUrl = "$provMnSBasePath/v1/someUriLdnFirstPart/someClassName=someId" - and: 'alternate Id cannot be matched' - alternateIdMatcher.getCmHandleIdByLongestMatchingAlternateId(*_) >> {throw new NoAlternateIdMatchFoundException('someUriLdnFirstPart/someClassName=someId')} - and: 'persistence service returns valid yangModelCmHandle' - inventoryPersistence.getYangModelCmHandle('cm-1') >> new YangModelCmHandle(id:'cm-1', dmiServiceName: 'someDmiService', dataProducerIdentifier: 'someDataProducerId', compositeState: new CompositeState(cmHandleState: READY)) - when: 'patch request is performed' - def response = mvc.perform(patch(provmnsUrl) - .contentType(new MediaType('application', 'json-patch+json')) - .content(patchJsonBody)) - .andReturn().response - then: 'response status is NOT_FOUND (404)' - assert response.status == HttpStatus.NOT_FOUND.value() - } - - def 'Patch request with no permission from coordination management.'() { - given: 'resource data url' - def url = "$provMnSBasePath/v1/someUriLdnFirstPart/someClassName=someId" - and: 'alternate Id can be matched' - alternateIdMatcher.getCmHandleIdByLongestMatchingAlternateId('/someUriLdnFirstPart/someClassName=someId', "/") >> 'cm-1' - and: 'persistence service returns valid yangModelCmHandle' - inventoryPersistence.getYangModelCmHandle('cm-1') >> new YangModelCmHandle(id:'cm-1', dmiServiceName: 'someDmiService', dataProducerIdentifier: 'someDataProducerId', compositeState: new CompositeState(cmHandleState: READY)) - and: 'the permission is denied (Policy Executor throws an exception)' - operationDetailsFactory.checkPermissionForEachPatchItem(*_) >> {throw new RuntimeException()} - when: 'patch data resource request is performed' - def response = mvc.perform(patch(url) - .contentType(new MediaType('application', 'json-patch+json')) - .content(patchJsonBody)) - .andReturn().response - then: 'response status is NOT_ACCEPTABLE (406)' - assert response.status == HttpStatus.NOT_ACCEPTABLE.value() - } - - def 'Patch request with too many operations.'() { - given: 'resource data url' - def url = "$provMnSBasePath/v1/someUriLdnFirstPart/someClassName=someId" - and: 'a patch request with more operations than the max allowed' - def patchItems = [] - for (def i = 0; i <= maxNumberOfPatchOperations; i++) { - patchItems.add(new PatchItem(op: 'REMOVE', path: 'someUriLdnFirstPart')) - } - def patchItemsJsonRequestBody = jsonObjectMapper.asJsonString(patchItems) - when: 'patch data resource request is performed' - def response = mvc.perform(patch(url) - .contentType(new MediaType('application', 'json-patch+json')) - .content(patchItemsJsonRequestBody)) - .andReturn().response - then: 'response status is PAYLOAD_TOO_LARGE (413)' - assert response.status == HttpStatus.PAYLOAD_TOO_LARGE.value() - } - - def 'Put resource data request where #scenario.'() { - given: 'resource data url' - def putUrl = "$provMnSBasePath/v1/someUriLdnFirstPart/someClassName=someId" - and: 'alternate Id can be matched' - alternateIdMatcher.getCmHandleIdByLongestMatchingAlternateId('/someUriLdnFirstPart/someClassName=someId', "/") >> 'cm-1' - and: 'persistence service returns yangModelCmHandle' - inventoryPersistence.getYangModelCmHandle('cm-1') >> new YangModelCmHandle(id:'cm-1', dmiServiceName: 'someDmiService', dataProducerIdentifier: dataProducerId, compositeState: new CompositeState(cmHandleState: state)) - and: 'dmi provides a response' - dmiRestClient.synchronousPutOperation(*_) >> new ResponseEntity<>('Some response from DMI service', HttpStatus.OK) - when: 'put data resource request is performed' - def response = mvc.perform(put(putUrl) - .contentType(MediaType.APPLICATION_JSON) - .content(jsonBody)) - .andReturn().response - then: 'response status is as expected' - assert response.status == expectedHttpStatus.value() - where: - scenario | dataProducerId | state || expectedHttpStatus - 'valid request is made' | 'someDataProducerId' | READY || HttpStatus.OK - 'dataProducerId is blank' | ' ' | READY || HttpStatus.UNPROCESSABLE_ENTITY - 'dataProducerId is null' | null | READY || HttpStatus.UNPROCESSABLE_ENTITY - 'cmHandle state is Advised' | 'someDataProducerId' | ADVISED || HttpStatus.NOT_ACCEPTABLE - } - - def 'Put resource data request with no match for alternate id.'() { - given: 'resource data url' - def putUrl = "$provMnSBasePath/v1/someUriLdnFirstPart/someClassName=someId" - and: 'alternate Id cannot be matched' - alternateIdMatcher.getCmHandleIdByLongestMatchingAlternateId(*_) >> {throw new NoAlternateIdMatchFoundException('someUriLdnFirstPart/someClassName=someId')} - and: 'persistence service returns valid yangModelCmHandle' - inventoryPersistence.getYangModelCmHandle('cm-1') >> new YangModelCmHandle(id:'cm-1', dmiServiceName: 'someDmiService', dataProducerIdentifier: 'someDataProducerId', compositeState: new CompositeState(cmHandleState: READY)) - when: 'put data resource request is performed' - def response = mvc.perform(put(putUrl) - .contentType(MediaType.APPLICATION_JSON) - .content(jsonBody)) - .andReturn().response - then: 'response status is NOT_FOUND (404)' - assert response.status == HttpStatus.NOT_FOUND.value() - } - - def 'Put resource data request with no permission from coordination management.'() { - given: 'resource data url' - def putUrl = "$provMnSBasePath/v1/someUriLdnFirstPart/someClassName=someId" - and: 'alternate Id can be matched' - alternateIdMatcher.getCmHandleIdByLongestMatchingAlternateId('/someUriLdnFirstPart/someClassName=someId', "/") >> 'cm-1' - and: 'persistence service returns valid yangModelCmHandle' - inventoryPersistence.getYangModelCmHandle('cm-1') >> new YangModelCmHandle(id:'cm-1', dmiServiceName: 'someDmiService', dataProducerIdentifier: 'someDataProducerId', compositeState: new CompositeState(cmHandleState: READY)) - and: 'policy executor throws exception (denied)' - policyExecutor.checkPermission(*_) >> {throw new RuntimeException()} - when: 'put data resource request is performed' - def response = mvc.perform(put(putUrl) - .contentType(MediaType.APPLICATION_JSON) - .content(jsonBody)) - .andReturn().response - then: 'response status is NOT_ACCEPTABLE (406)' - assert response.status == HttpStatus.NOT_ACCEPTABLE.value() - } - - def 'Delete resource data request where #scenario.'() { - given: 'resource data url' - def deleteUrl = "$provMnSBasePath/v1/someUriLdnFirstPart/someClassName=someId" - and: 'alternate Id can be matched' - alternateIdMatcher.getCmHandleIdByLongestMatchingAlternateId('/someUriLdnFirstPart/someClassName=someId', "/") >> 'cm-1' - and: 'persistence service returns yangModelCmHandle' - inventoryPersistence.getYangModelCmHandle('cm-1') >> new YangModelCmHandle(id:'cm-1', dmiServiceName: 'someDmiService', dataProducerIdentifier: dataProducerId, compositeState: new CompositeState(cmHandleState: state)) - and: 'dmi provides a response' - dmiRestClient.synchronousDeleteOperation(*_) >> new ResponseEntity<>('Some response from DMI service', HttpStatus.OK) - when: 'get data resource request is performed' - def response = mvc.perform(delete(deleteUrl)).andReturn().response - then: 'response status as expected' - assert response.status == expectedHttpStatus.value() - where: - scenario | dataProducerId | state || expectedHttpStatus - 'valid request is made' | 'someDataProducerId' | READY || HttpStatus.OK - 'dataProducerId is blank' | ' ' | READY || HttpStatus.UNPROCESSABLE_ENTITY - 'dataProducerId is null' | null | READY || HttpStatus.UNPROCESSABLE_ENTITY - 'cmHandle state is Advised' | 'someDataProducerId' | ADVISED || HttpStatus.NOT_ACCEPTABLE - } - - def 'Delete resource data request with no match for alternate id.'() { - given: 'resource data url' - def deleteUrl = "$provMnSBasePath/v1/someUriLdnFirstPart/someClassName=someId" - and: 'alternate Id cannot be matched' - alternateIdMatcher.getCmHandleIdByLongestMatchingAlternateId(*_) >> {throw new NoAlternateIdMatchFoundException('someUriLdnFirstPart/someClassName=someId')} - and: 'persistence service returns valid yangModelCmHandle' - inventoryPersistence.getYangModelCmHandle('cm-1') >> new YangModelCmHandle(id:'cm-1', dmiServiceName: 'someDmiService', dataProducerIdentifier: 'someDataProducerId', compositeState: new CompositeState(cmHandleState: READY)) - when: 'delete data resource request is performed' - def response = mvc.perform(delete(deleteUrl)).andReturn().response - then: 'response status is NOT_FOUND (404)' - assert response.status == HttpStatus.NOT_FOUND.value() - } - - def 'Delete resource data request with no permission from coordination management.'() { - given: 'resource data url' - def deleteUrl = "$provMnSBasePath/v1/someUriLdnFirstPart/someClassName=someId" - and: 'alternate Id can be matched' - alternateIdMatcher.getCmHandleIdByLongestMatchingAlternateId('/someUriLdnFirstPart/someClassName=someId', "/") >> 'cm-1' - and: 'persistence service returns valid yangModelCmHandle' - inventoryPersistence.getYangModelCmHandle('cm-1') >> new YangModelCmHandle(id:'cm-1', dmiServiceName: 'someDmiService', dataProducerIdentifier: 'someDataProducerId', compositeState: new CompositeState(cmHandleState: READY)) - and: 'policy executor throws exception (denied)' - policyExecutor.checkPermission(*_) >> {throw new RuntimeException()} - when: 'delete data resource request is performed' - def response = mvc.perform(delete(deleteUrl)).andReturn().response - then: 'response status is NOT_ACCEPTABLE (406)' - assert response.status == HttpStatus.NOT_ACCEPTABLE.value() - } - - def 'Invalid path passed in to provmns interface, #scenario.'() { - given: 'an invalid path' - def url = "$provMnSBasePath/v1/" + invalidPath - when: 'get data resource request is performed' - mvc.perform(get(url).contentType(MediaType.APPLICATION_JSON)).andReturn().response - then: 'invalid path exception is thrown' - thrown(ServletException) - where: - scenario | invalidPath - 'Missing URI-LDN-first-part' | 'someClassName=someId' - 'Missing ClassName and Id' | 'someUriLdnFirstPart/' - } -} diff --git a/cps-ncmp-service/src/main/java/org/onap/cps/ncmp/api/exceptions/ProvMnSException.java b/cps-ncmp-service/src/main/java/org/onap/cps/ncmp/api/exceptions/ProvMnSException.java index 036c46797f..12f87f77ab 100644 --- a/cps-ncmp-service/src/main/java/org/onap/cps/ncmp/api/exceptions/ProvMnSException.java +++ b/cps-ncmp-service/src/main/java/org/onap/cps/ncmp/api/exceptions/ProvMnSException.java @@ -20,16 +20,34 @@ package org.onap.cps.ncmp.api.exceptions; -public class ProvMnSException extends NcmpException { +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.springframework.http.HttpStatus; + +@NoArgsConstructor +@Getter +@Setter +public class ProvMnSException extends RuntimeException { + + private String httpMethodName; + private HttpStatus httpStatus; + private String title; /** * Constructor. * - * @param message exception message - * @param details exception details + * @param httpMethodName original REST method + * @param httpStatus http status to be reported for this exception + * @param title 3GPP error title (detail) */ - public ProvMnSException(final String message, final String details) { - super(message, details); + public ProvMnSException(final String httpMethodName, + final HttpStatus httpStatus, + final String title) { + super(httpMethodName + " failed"); + this.httpMethodName = httpMethodName; + this.httpStatus = httpStatus; + this.title = title; } } diff --git a/cps-ncmp-service/src/main/java/org/onap/cps/ncmp/impl/data/policyexecutor/OperationDetailsFactory.java b/cps-ncmp-service/src/main/java/org/onap/cps/ncmp/impl/data/policyexecutor/OperationDetailsFactory.java index 72c177bacf..c780f639b4 100644 --- a/cps-ncmp-service/src/main/java/org/onap/cps/ncmp/impl/data/policyexecutor/OperationDetailsFactory.java +++ b/cps-ncmp-service/src/main/java/org/onap/cps/ncmp/impl/data/policyexecutor/OperationDetailsFactory.java @@ -20,9 +20,10 @@ package org.onap.cps.ncmp.impl.data.policyexecutor; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; +import static org.onap.cps.ncmp.api.data.models.OperationType.CREATE; +import static org.onap.cps.ncmp.api.data.models.OperationType.DELETE; +import static org.onap.cps.ncmp.api.data.models.OperationType.UPDATE; + import com.google.common.base.Strings; import java.util.HashMap; import java.util.List; @@ -31,10 +32,10 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.onap.cps.ncmp.api.data.models.OperationType; import org.onap.cps.ncmp.api.exceptions.ProvMnSException; -import org.onap.cps.ncmp.impl.inventory.models.YangModelCmHandle; -import org.onap.cps.ncmp.impl.provmns.RequestPathParameters; +import org.onap.cps.ncmp.impl.provmns.RequestParameters; import org.onap.cps.ncmp.impl.provmns.model.PatchItem; import org.onap.cps.utils.JsonObjectMapper; +import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; @Slf4j @@ -44,88 +45,58 @@ public class OperationDetailsFactory { private static final String ATTRIBUTE_NAME_SEPARATOR = "/"; private static final String REGEX_FOR_LEADING_AND_TRAILING_SEPARATORS = "(^/)|(/$)"; - private static final String NO_AUTHORIZATION = null; - private static final String UNSUPPORTED_OPERATION = "UNSUPPORTED_OP"; private final JsonObjectMapper jsonObjectMapper; - private final ObjectMapper objectMapper; - private final PolicyExecutor policyExecutor; /** - * Build an operation details object from ProvMnS request details and send it to Policy Executor. + * Create OperationDetails object from ProvMnS request details. * - * @param requestPathParameters request parameters including uri-ldn-first-part, className and id - * @param patchItems provided request list of patch Items - * @param yangModelCmHandle representation of the cm handle to check + * @param requestParameters request parameters including uri-ldn-first-part, className and id + * @param patchItem provided request payload + * @return OperationDetails object */ - public void checkPermissionForEachPatchItem(final RequestPathParameters requestPathParameters, - final List patchItems, - final YangModelCmHandle yangModelCmHandle) { - OperationDetails operationDetails; - for (final PatchItem patchItem : patchItems) { - switch (patchItem.getOp()) { - case ADD -> operationDetails = buildCreateOperationDetails(OperationType.CREATE, requestPathParameters, - patchItem.getValue()); - case REPLACE -> operationDetails = buildCreateOperationDetailsForUpdate(OperationType.UPDATE, - requestPathParameters, - patchItem); - case REMOVE -> operationDetails = buildDeleteOperationDetails(requestPathParameters.toAlternateId()); - default -> throw new ProvMnSException(UNSUPPORTED_OPERATION, - "Unsupported Patch Operation Type: " + patchItem.getOp().getValue()); - } - policyExecutor.checkPermission(yangModelCmHandle, - OperationType.fromOperationName(operationDetails.operation()), - NO_AUTHORIZATION, - requestPathParameters.toAlternateId(), - jsonObjectMapper.asJsonString(operationDetails) - ); + public OperationDetails buildOperationDetails(final RequestParameters requestParameters, + final PatchItem patchItem) { + final OperationDetails operationDetails; + switch (patchItem.getOp()) { + case ADD: + operationDetails = buildCreateOperationDetails(CREATE, requestParameters, patchItem.getValue()); + break; + case REPLACE: + if (patchItem.getPath().contains("#/attributes")) { + operationDetails = buildCreateOperationDetailsForUpdateWithHash(requestParameters, patchItem); + } else { + operationDetails = buildCreateOperationDetails(UPDATE, requestParameters, patchItem.getValue()); + } + break; + case REMOVE: + operationDetails = buildDeleteOperationDetails(requestParameters.toTargetFdn()); + break; + default: + throw new ProvMnSException("PATCH", HttpStatus.UNPROCESSABLE_ENTITY, + "Unsupported Patch Operation Type: " + patchItem.getOp().getValue()); } + return operationDetails; } /** * Build a CreateOperationDetails object from ProvMnS request details. * * @param operationType Type of operation create, update. - * @param requestPathParameters request parameters including uri-ldn-first-part, className and id + * @param requestParameters request parameters including uri-ldn-first-part, className and id * @param resourceAsObject provided request payload * @return CreateOperationDetails object */ public CreateOperationDetails buildCreateOperationDetails(final OperationType operationType, - final RequestPathParameters requestPathParameters, + final RequestParameters requestParameters, final Object resourceAsObject) { - final ResourceObjectDetails resourceObjectDetails = createResourceObjectDetails(resourceAsObject, - requestPathParameters); - + requestParameters); final OperationEntry operationEntry = new OperationEntry(resourceObjectDetails.id(), - resourceObjectDetails.attributes()); - - final Map> operationEntriesPerObjectClass = - Map.of(resourceObjectDetails.objectClass(), List.of(operationEntry)); - - return new CreateOperationDetails( - operationType.name(), - requestPathParameters.getUriLdnFirstPart(), - operationEntriesPerObjectClass - ); - } - - /** - * Build a CreateOperationDetails object from ProvMnS request details. - * - * @param operationType Type of operation create, update. - * @param requestPathParameters request parameters including uri-ldn-first-part, className and id - * @param patchItem provided request - * @return CreateOperationDetails object - */ - public CreateOperationDetails buildCreateOperationDetailsForUpdate(final OperationType operationType, - final RequestPathParameters requestPathParameters, - final PatchItem patchItem) { - if (patchItem.getPath().contains("#/attributes")) { - return buildCreateOperationDetailsForUpdateWithHash(operationType, requestPathParameters, patchItem); - } else { - return buildCreateOperationDetails(operationType, requestPathParameters, patchItem.getValue()); - } + resourceObjectDetails.attributes()); + return new CreateOperationDetails(operationType.name(), + requestParameters.getUriLdnFirstPart(), + Map.of(resourceObjectDetails.objectClass(), List.of(operationEntry))); } /** @@ -135,63 +106,49 @@ public class OperationDetailsFactory { * @return DeleteOperationDetails object */ public DeleteOperationDetails buildDeleteOperationDetails(final String alternateId) { - return new DeleteOperationDetails(OperationType.DELETE.name(), alternateId); + return new DeleteOperationDetails(DELETE.name(), alternateId); } + @SuppressWarnings("unchecked") private ResourceObjectDetails createResourceObjectDetails(final Object resourceAsObject, - final RequestPathParameters requestPathParameters) { - try { - final String resourceAsJson = jsonObjectMapper.asJsonString(resourceAsObject); - final TypeReference> typeReference = new TypeReference<>() {}; - final Map resourceAsMap = objectMapper.readValue(resourceAsJson, typeReference); - - return new ResourceObjectDetails(requestPathParameters.getId(), - extractObjectClass(resourceAsMap, requestPathParameters), - resourceAsMap.get("attributes")); - } catch (final JsonProcessingException e) { - log.debug("JSON processing error: {}", e.getMessage()); - throw new ProvMnSException("Cannot convert Resource Object", e.getMessage()); - } + final RequestParameters requestParameters) { + final String resourceAsJson = jsonObjectMapper.asJsonString(resourceAsObject); + final Map resourceAsMap = jsonObjectMapper.convertJsonString(resourceAsJson, Map.class); + return new ResourceObjectDetails(requestParameters.getId(), + extractObjectClass(resourceAsMap, requestParameters), + resourceAsMap.get("attributes")); + } private static String extractObjectClass(final Map resourceAsMap, - final RequestPathParameters requestPathParameters) { + final RequestParameters requestParameters) { final String objectClass = (String) resourceAsMap.get("objectClass"); if (Strings.isNullOrEmpty(objectClass)) { - return requestPathParameters.getClassName(); + return requestParameters.getClassName(); } return objectClass; } - - private CreateOperationDetails buildCreateOperationDetailsForUpdateWithHash(final OperationType operationType, - final RequestPathParameters requestPathParameters, + private CreateOperationDetails buildCreateOperationDetailsForUpdateWithHash( + final RequestParameters requestParameters, final PatchItem patchItem) { final Map> operationEntriesPerObjectClass = new HashMap<>(); - final String className = requestPathParameters.getClassName(); - + final String className = requestParameters.getClassName(); final Map attributeHierarchyAsMap = createNestedMap(patchItem); - - final OperationEntry operationEntry = new OperationEntry(requestPathParameters.getId(), - attributeHierarchyAsMap); + final OperationEntry operationEntry = new OperationEntry(requestParameters.getId(), attributeHierarchyAsMap); operationEntriesPerObjectClass.put(className, List.of(operationEntry)); - - return new CreateOperationDetails(operationType.getOperationName(), - requestPathParameters.getUriLdnFirstPart(), - operationEntriesPerObjectClass); + return new CreateOperationDetails(UPDATE.getOperationName(), requestParameters.getUriLdnFirstPart(), + operationEntriesPerObjectClass); } private Map createNestedMap(final PatchItem patchItem) { final Map attributeHierarchyMap = new HashMap<>(); Map currentLevel = attributeHierarchyMap; - final String[] attributeHierarchyNames = patchItem.getPath().split("#/attributes")[1] .replaceAll(REGEX_FOR_LEADING_AND_TRAILING_SEPARATORS, "") .split(ATTRIBUTE_NAME_SEPARATOR); - for (int level = 0; level < attributeHierarchyNames.length; level++) { final String attributeName = attributeHierarchyNames[level]; - if (isLastLevel(attributeHierarchyNames, level)) { currentLevel.put(attributeName, patchItem.getValue()); } else { diff --git a/cps-ncmp-service/src/main/java/org/onap/cps/ncmp/impl/provmns/ParameterMapper.java b/cps-ncmp-service/src/main/java/org/onap/cps/ncmp/impl/provmns/ParameterMapper.java index 1ce556c936..fd53be48ee 100644 --- a/cps-ncmp-service/src/main/java/org/onap/cps/ncmp/impl/provmns/ParameterMapper.java +++ b/cps-ncmp-service/src/main/java/org/onap/cps/ncmp/impl/provmns/ParameterMapper.java @@ -23,6 +23,7 @@ package org.onap.cps.ncmp.impl.provmns; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import org.onap.cps.ncmp.api.exceptions.ProvMnSException; +import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; @Service @@ -30,34 +31,47 @@ import org.springframework.stereotype.Service; public class ParameterMapper { private static final String PROVMNS_BASE_PATH = "ProvMnS/v\\d+/"; - private static final String INVALID_PATH_DETAILS_FORMAT = "%s not a valid path"; + private static final String INVALID_PATH_DETAILS_TEMPLATE = "%s not a valid path"; + private static final int PATH_VARIABLES_EXPECTED_LENGTH = 2; + private static final int OBJECT_INSTANCE_INDEX = 1; /** - * Converts HttpServletRequest to RequestPathParameters. + * Converts HttpServletRequest to RequestParameters. * * @param httpServletRequest HttpServletRequest object containing the path - * @return RequestPathParameters object containing parsed parameters + * @return RequestParameters object containing http method and parsed parameters */ - public RequestPathParameters extractRequestParameters(final HttpServletRequest httpServletRequest) { + public RequestParameters extractRequestParameters(final HttpServletRequest httpServletRequest) { final String uriPath = (String) httpServletRequest.getAttribute( "org.springframework.web.servlet.HandlerMapping.pathWithinHandlerMapping"); final String[] pathVariables = uriPath.split(PROVMNS_BASE_PATH); + if (pathVariables.length != PATH_VARIABLES_EXPECTED_LENGTH) { + throwProvMnSException(httpServletRequest.getMethod(), uriPath); + } final int lastSlashIndex = pathVariables[1].lastIndexOf('/'); - final RequestPathParameters requestPathParameters = new RequestPathParameters(); + final RequestParameters requestParameters = new RequestParameters(); + requestParameters.setHttpMethodName(httpServletRequest.getMethod()); final String classNameAndId; if (lastSlashIndex < 0) { - requestPathParameters.setUriLdnFirstPart(""); - classNameAndId = pathVariables[1]; + requestParameters.setUriLdnFirstPart(""); + classNameAndId = pathVariables[OBJECT_INSTANCE_INDEX]; } else { - requestPathParameters.setUriLdnFirstPart("/" + pathVariables[1].substring(0, lastSlashIndex)); - classNameAndId = pathVariables[1].substring(lastSlashIndex + 1); + final String uriLdnFirstPart = "/" + pathVariables[OBJECT_INSTANCE_INDEX].substring(0, lastSlashIndex); + requestParameters.setUriLdnFirstPart(uriLdnFirstPart); + classNameAndId = pathVariables[OBJECT_INSTANCE_INDEX].substring(lastSlashIndex + 1); } final String[] splitClassNameId = classNameAndId.split("=", 2); if (splitClassNameId.length != 2) { - throw new ProvMnSException("not a valid path", String.format(INVALID_PATH_DETAILS_FORMAT, uriPath)); + throwProvMnSException(httpServletRequest.getMethod(), uriPath); } - requestPathParameters.setClassName(splitClassNameId[0]); - requestPathParameters.setId(splitClassNameId[1]); - return requestPathParameters; + requestParameters.setClassName(splitClassNameId[0]); + requestParameters.setId(splitClassNameId[1]); + return requestParameters; + } + + private void throwProvMnSException(final String httpMethodName, final String uriPath) { + final String title = String.format(INVALID_PATH_DETAILS_TEMPLATE, uriPath); + throw new ProvMnSException(httpMethodName, HttpStatus.UNPROCESSABLE_ENTITY, title); } + } diff --git a/cps-ncmp-service/src/main/java/org/onap/cps/ncmp/impl/provmns/ParametersBuilder.java b/cps-ncmp-service/src/main/java/org/onap/cps/ncmp/impl/provmns/ParametersBuilder.java index 30b4d21668..56ce6ec0c2 100644 --- a/cps-ncmp-service/src/main/java/org/onap/cps/ncmp/impl/provmns/ParametersBuilder.java +++ b/cps-ncmp-service/src/main/java/org/onap/cps/ncmp/impl/provmns/ParametersBuilder.java @@ -38,24 +38,26 @@ public class ParametersBuilder { /** * Creates a UrlTemplateParameters object containing the relevant fields for read requests. * - * @param scope Provided className parameter. - * @param filter Filter string. - * @param attributes Attributes List. - * @param fields Fields list - * @param dataNodeSelector dataNodeSelector parameter - * @param yangModelCmHandle yangModelCmHandle object for resolved alternate ID + * @param yangModelCmHandle yangModelCmHandle object for resolved alternate ID + * @param targetFdn Target FDN for the resource + * @param scope Provided className parameter + * @param filter Filter string + * @param attributes Attributes List + * @param fields Fields list + * @param dataNodeSelector dataNodeSelector parameter * @return UrlTemplateParameters object. */ - public UrlTemplateParameters createUrlTemplateParametersForRead(final Scope scope, - final String filter, - final List attributes, - final List fields, - final ClassNameIdGetDataNodeSelectorParameter dataNodeSelector, - final YangModelCmHandle yangModelCmHandle, - final RequestPathParameters requestPathParameters) { + public UrlTemplateParameters createUrlTemplateParametersForRead(final YangModelCmHandle yangModelCmHandle, + final String targetFdn, + final Scope scope, + final String filter, + final List attributes, + final List fields, + final ClassNameIdGetDataNodeSelectorParameter + dataNodeSelector) { final String dmiServiceName = yangModelCmHandle.resolveDmiServiceName(DATA); return RestServiceUrlTemplateBuilder.newInstance() - .fixedPathSegment(requestPathParameters.toAlternateId()) + .fixedPathSegment(targetFdn) .queryParameter("scopeType", scope.getScopeType() != null ? scope.getScopeType().getValue() : null) .queryParameter("scopeLevel", scope.getScopeLevel() != null ? scope.getScopeLevel().toString() : null) .queryParameter("filter", filter) @@ -68,15 +70,14 @@ public class ParametersBuilder { /** * Creates a UrlTemplateParameters object containing the relevant fields for a write requests. * - * @param yangModelCmHandle yangModelCmHandle object for resolved alternate ID - * @param requestPathParameters request path parameters. + * @param yangModelCmHandle yangModelCmHandle object for resolved alternate ID * @return UrlTemplateParameters object. */ public UrlTemplateParameters createUrlTemplateParametersForWrite(final YangModelCmHandle yangModelCmHandle, - final RequestPathParameters requestPathParameters) { + final String targetFdn) { final String dmiServiceName = yangModelCmHandle.resolveDmiServiceName(DATA); return RestServiceUrlTemplateBuilder.newInstance() - .fixedPathSegment(requestPathParameters.toAlternateId()) + .fixedPathSegment(targetFdn) .createUrlTemplateParameters(dmiServiceName, "ProvMnS"); } diff --git a/cps-ncmp-service/src/main/java/org/onap/cps/ncmp/impl/provmns/RequestPathParameters.java b/cps-ncmp-service/src/main/java/org/onap/cps/ncmp/impl/provmns/RequestParameters.java similarity index 85% rename from cps-ncmp-service/src/main/java/org/onap/cps/ncmp/impl/provmns/RequestPathParameters.java rename to cps-ncmp-service/src/main/java/org/onap/cps/ncmp/impl/provmns/RequestParameters.java index 956b190d2a..a85bb249b4 100644 --- a/cps-ncmp-service/src/main/java/org/onap/cps/ncmp/impl/provmns/RequestPathParameters.java +++ b/cps-ncmp-service/src/main/java/org/onap/cps/ncmp/impl/provmns/RequestParameters.java @@ -25,18 +25,19 @@ import lombok.Setter; @Getter @Setter -public class RequestPathParameters { +public class RequestParameters { + private String httpMethodName; private String uriLdnFirstPart; private String className; private String id; /** - * Gets alternate id from combining URI-LDN-First-Part, className and id. + * Gets target FDN by combining URI-LDN-First-Part, className and id. * - * @return String of alternate id. + * @return String of FDN */ - public String toAlternateId() { + public String toTargetFdn() { return uriLdnFirstPart + "/" + className + "=" + id; } } diff --git a/cps-ncmp-service/src/test/groovy/org/onap/cps/ncmp/impl/data/policyexecutor/OperationDetailsFactorySpec.groovy b/cps-ncmp-service/src/test/groovy/org/onap/cps/ncmp/impl/data/policyexecutor/OperationDetailsFactorySpec.groovy index 89fe5ec0fc..ab4abea689 100644 --- a/cps-ncmp-service/src/test/groovy/org/onap/cps/ncmp/impl/data/policyexecutor/OperationDetailsFactorySpec.groovy +++ b/cps-ncmp-service/src/test/groovy/org/onap/cps/ncmp/impl/data/policyexecutor/OperationDetailsFactorySpec.groovy @@ -20,36 +20,31 @@ package org.onap.cps.ncmp.impl.data.policyexecutor -import com.fasterxml.jackson.core.JsonProcessingException + import com.fasterxml.jackson.databind.ObjectMapper -import org.onap.cps.ncmp.api.exceptions.NcmpException import org.onap.cps.ncmp.api.exceptions.ProvMnSException -import org.onap.cps.ncmp.impl.inventory.models.YangModelCmHandle; -import org.onap.cps.ncmp.impl.provmns.RequestPathParameters; -import org.onap.cps.ncmp.impl.provmns.model.PatchItem; +import org.onap.cps.ncmp.impl.provmns.RequestParameters +import org.onap.cps.ncmp.impl.provmns.model.PatchItem import org.onap.cps.ncmp.impl.provmns.model.ResourceOneOf -import org.onap.cps.utils.JsonObjectMapper; -import spock.lang.Specification; +import org.onap.cps.utils.JsonObjectMapper +import spock.lang.Specification -import static org.onap.cps.ncmp.api.data.models.OperationType.CREATE; -import static org.onap.cps.ncmp.api.data.models.OperationType.UPDATE; -import static org.onap.cps.ncmp.api.data.models.OperationType.DELETE; +import static org.onap.cps.ncmp.api.data.models.OperationType.CREATE +import static org.onap.cps.ncmp.api.data.models.OperationType.DELETE +import static org.onap.cps.ncmp.api.data.models.OperationType.UPDATE class OperationDetailsFactorySpec extends Specification { - def spiedObjectMapper = Spy(ObjectMapper) - def jsonObjectMapper = new JsonObjectMapper(spiedObjectMapper) - def policyExecutor = Mock(PolicyExecutor) + def jsonObjectMapper = new JsonObjectMapper(new ObjectMapper()) - OperationDetailsFactory objectUnderTest = new OperationDetailsFactory(jsonObjectMapper, spiedObjectMapper, policyExecutor) + OperationDetailsFactory objectUnderTest = new OperationDetailsFactory(jsonObjectMapper) static def complexValueAsResource = new ResourceOneOf(id: 'myId', attributes: ['myAttribute1:myValue1', 'myAttribute2:myValue2'], objectClass: 'myClassName') static def simpleValueAsResource = new ResourceOneOf(id: 'myId', attributes: ['simpleAttribute:1'], objectClass: 'myClassName') - static def yangModelCmHandle = new YangModelCmHandle(id: 'someId') def 'Build create operation details with all properties.'() { given: 'request parameters and resource' - def requestPathParameters = new RequestPathParameters(uriLdnFirstPart: 'my uri', className: 'class in uri', id: 'my id') + def requestPathParameters = new RequestParameters(uriLdnFirstPart: 'my uri', className: 'class in uri', id: 'my id') def resource = new ResourceOneOf(id: 'some resource id', objectClass: 'class in resource') when: 'create operation details are built' def result = objectUnderTest.buildCreateOperationDetails(CREATE, requestPathParameters, resource) @@ -61,7 +56,7 @@ class OperationDetailsFactorySpec extends Specification { def 'Build replace operation details with all properties where class name in body is #scenario.'() { given: 'request parameters and resource' - def requestPathParameters = new RequestPathParameters(uriLdnFirstPart: 'my uri', className: 'class in uri', id: 'some id') + def requestPathParameters = new RequestParameters(uriLdnFirstPart: 'my uri', className: 'class in uri', id: 'some id') def resource = new ResourceOneOf(id: 'some resource id', objectClass: classNameInBody) when: 'replace operation details are built' def result = objectUnderTest.buildCreateOperationDetails(CREATE, requestPathParameters, resource) @@ -77,76 +72,42 @@ class OperationDetailsFactorySpec extends Specification { def 'Build delete operation details with all properties'() { given: 'request parameters' - def requestPathParameters = new RequestPathParameters(uriLdnFirstPart: 'my uri', className: 'classNameInUri', id: 'myId') + def requestPathParameters = new RequestParameters(uriLdnFirstPart: 'my uri', className: 'classNameInUri', id: 'myId') when: 'delete operation details are built' - def result = objectUnderTest.buildDeleteOperationDetails(requestPathParameters.toAlternateId()) + def result = objectUnderTest.buildDeleteOperationDetails(requestPathParameters.toTargetFdn()) then: 'all details are correct' assert result.targetIdentifier == 'my uri/classNameInUri=myId' } def 'Single patch operation with #patchOperationType checks correct operation type.'() { given: 'request parameters and single patch item' - def requestPathParameters = new RequestPathParameters(uriLdnFirstPart: 'some uri', className: 'some class') + def requestPathParameters = new RequestParameters(uriLdnFirstPart: 'some uri', className: 'some class') def resource = new ResourceOneOf(id: 'some resource id') def patchItem = new PatchItem(op: patchOperationType, 'path':'some uri', value: resource) - when: 'patch operation is processed' - objectUnderTest.checkPermissionForEachPatchItem(requestPathParameters, [patchItem], yangModelCmHandle) - then: 'policy executor is called with correct operation type' - 1 * policyExecutor.checkPermission(yangModelCmHandle, expectedPolicyExecutorOperationType, _, _, _) + when: 'operation details is created' + def result = objectUnderTest.buildOperationDetails(requestPathParameters, patchItem) + then: 'it has the correct operation type (for Policy Executor check)' + assert result.operation() == expectedPolicyExecutorOperationType.name() where: 'following operations are used' patchOperationType | expectedPolicyExecutorOperationType 'ADD' | CREATE 'REPLACE' | UPDATE - } - - def 'Single patch operation with REMOVE checks correct operation type.'() { - given: 'request parameters and single remove patch item' - def requestPathParameters = new RequestPathParameters(uriLdnFirstPart: 'some uri') - def patchItem = new PatchItem(op: 'REMOVE') - when: 'patch operation is processed' - objectUnderTest.checkPermissionForEachPatchItem(requestPathParameters, [patchItem], yangModelCmHandle) - then: 'policy executor is called with DELETE operation type' - 1 * policyExecutor.checkPermission(yangModelCmHandle, DELETE, _, _, _) - } - - def 'Multiple patch operations invoke policy executor correct number of times in order.'() { - given: 'request parameters and multiple patch items' - def requestPathParameters = new RequestPathParameters(uriLdnFirstPart: 'some uri') - def resource = new ResourceOneOf(id: 'some resource id', objectClass: 'some class') - def patchItemsList = [ - new PatchItem(op: 'ADD', 'path':'some uri', value: resource), - new PatchItem(op: 'REPLACE', 'path':'some uri', value: resource), - new PatchItem(op: 'REMOVE', 'path':'some uri') - ] - when: 'patch operations are processed' - objectUnderTest.checkPermissionForEachPatchItem(requestPathParameters, patchItemsList, yangModelCmHandle) - then: 'policy executor is checked for create first' - 1 * policyExecutor.checkPermission(yangModelCmHandle, CREATE, _, _, _) - then: 'update is next' - 1 * policyExecutor.checkPermission(yangModelCmHandle, UPDATE, _, _, _) - then: 'and finally delete' - 1 * policyExecutor.checkPermission(yangModelCmHandle, DELETE, _, _, _) + 'REMOVE' | DELETE } def 'Build policy executor patch operation details with single replace operation and #scenario.'() { given: 'a requestParameter and a patchItem list' - def requestPathParameters = new RequestPathParameters(uriLdnFirstPart: 'some uri', className: 'some class') - def pathItems = [new PatchItem(op: 'REPLACE', 'path':"some uri${suffix}", value: value)] + def requestPathParameters = new RequestParameters(uriLdnFirstPart: 'some uri', className: 'some class') + def pathItem = new PatchItem(op: 'REPLACE', 'path':"some uri${suffix}", value: value) when: 'patch operation details are checked' - objectUnderTest.checkPermissionForEachPatchItem(requestPathParameters, pathItems, yangModelCmHandle) - then: 'policyExecutor is called with correct payload' - 1 * policyExecutor.checkPermission( - yangModelCmHandle, - UPDATE, - null, - requestPathParameters.toAlternateId(), - { json -> assert json.contains(attributesValueInOperation) } // check for more details eg. verify type - ) + def result = objectUnderTest.buildOperationDetails(requestPathParameters, pathItem) + then: 'Attribute Value in operation is correct' + result.changeRequest.values()[0].attributes[0] == expectedAttributesValueInOperation where: 'attributes are set using # or resource' - scenario | suffix | value || attributesValueInOperation - 'set simple value using #' | '#/attributes/simpleAttribute' | 1 || '{"simpleAttribute":1}' - 'set simple value using resource' | '' | simpleValueAsResource || '["simpleAttribute:1"]' - 'set complex value using resource' | '' | complexValueAsResource || '["myAttribute1:myValue1","myAttribute2:myValue2"]' + scenario | suffix | value || expectedAttributesValueInOperation + 'set simple value using #' | '#/attributes/simpleAttribute' | 1 || [simpleAttribute:1] + 'set simple value using resource' | '' | simpleValueAsResource || ['simpleAttribute:1'] + 'set complex value using resource' | '' | complexValueAsResource || ["myAttribute1:myValue1","myAttribute2:myValue2"] } def 'Build an attribute map with different depths of hierarchy with #scenario.'() { @@ -163,19 +124,20 @@ class OperationDetailsFactorySpec extends Specification { 'set a complex attribute' | 'myUriLdnFirstPart#/attributes/complexAttribute/simpleAttribute' || 'complexAttribute' || '[simpleAttribute:123]' } - def 'Build policy executor patch operation details from ProvMnS request parameters with invalid op.'() { - given: 'a provMnsRequestParameter and a patchItem list' - def path = new RequestPathParameters(uriLdnFirstPart: 'some uri', className: 'some class') - def patchItemsList = [new PatchItem(op: 'TEST', 'path':'some uri')] - when: 'a build is attempted with an invalid op' - objectUnderTest.checkPermissionForEachPatchItem(path, patchItemsList, yangModelCmHandle) + def 'Attempt to Build Operation details with unsupported op (MOVE).'() { + given: 'a provMnsRequestParameter and a patchItem' + def path = new RequestParameters(uriLdnFirstPart: 'some uri', className: 'some class') + def patchItem = new PatchItem(op: 'MOVE', 'path':'some uri') + when: 'a build is attempted with an unsupported op' + objectUnderTest.buildOperationDetails(path, patchItem) then: 'the result is as expected (exception thrown)' - thrown(ProvMnSException) + def exceptionThrown = thrown(ProvMnSException) + assert exceptionThrown.title == 'Unsupported Patch Operation Type: move' } def 'Build policy executor create operation details from ProvMnS request parameters where objectClass in resource #scenario.'() { given: 'a provMnsRequestParameter and a resource' - def requestPathParameters = new RequestPathParameters(uriLdnFirstPart: 'some uri', className: 'class in uri', id:'my id') + def requestPathParameters = new RequestParameters(uriLdnFirstPart: 'some uri', className: 'class in uri', id:'my id') def resource = new ResourceOneOf(id: 'some resource id', objectClass: objectInResouce) when: 'a configurationManagementOperation is created and converted to JSON' def result = objectUnderTest.buildCreateOperationDetails(CREATE, requestPathParameters, resource) @@ -189,18 +151,4 @@ class OperationDetailsFactorySpec extends Specification { 'null' | null || 'class in uri' } - def 'Build Policy Executor Operation Details with a exception during conversion.'() { - given: 'a provMnsRequestParameter and a resource' - def requestPathParameters = new RequestPathParameters(uriLdnFirstPart: 'some uri', className: 'some class') - def resource = new ResourceOneOf(id: 'some resource id') - and: 'json object mapper throws an exception' - spiedObjectMapper.readValue(*_) >> { throw new JsonProcessingException('original exception message') } - when: 'a configurationManagementOperation is created and converted to JSON' - objectUnderTest.buildCreateOperationDetails(CREATE, requestPathParameters, resource) - then: 'the expected exception is throw and contains the original message' - def thrown = thrown(NcmpException) - assert thrown.message.contains('Cannot convert Resource Object') - assert thrown.details.contains('original exception message') - } - } diff --git a/cps-ncmp-service/src/test/groovy/org/onap/cps/ncmp/impl/provmns/ParameterMapperSpec.groovy b/cps-ncmp-service/src/test/groovy/org/onap/cps/ncmp/impl/provmns/ParameterMapperSpec.groovy index 3f9faa6d67..3dad062903 100644 --- a/cps-ncmp-service/src/test/groovy/org/onap/cps/ncmp/impl/provmns/ParameterMapperSpec.groovy +++ b/cps-ncmp-service/src/test/groovy/org/onap/cps/ncmp/impl/provmns/ParameterMapperSpec.groovy @@ -50,17 +50,21 @@ class ParameterMapperSpec extends Specification { } def 'Attempt to extract request parameters with #scenario.'() { - given: 'a http request with all the required parts' + given: 'a http request with invalid path' mockHttpServletRequest.getAttribute(uriPathAttributeName) >> path + mockHttpServletRequest.getMethod() >> 'GET' when: 'attempt to extract the request parameters' objectUnderTest.extractRequestParameters(mockHttpServletRequest) - then: 'a ProvMnS exception is thrown with message about the path being invalid' + then: 'a ProvMnS exception is thrown' def thrown = thrown(ProvMnSException) - assert thrown.message == 'not a valid path' - then: 'the details contain the faulty path' - assert thrown.details.contains(path) - where: 'the following paths are used' - scenario | path - 'No = After (last) class name' | 'ProvMnS/v1/someOtherClass=someId/myClass' + assert thrown.message == 'GET failed' + and: 'the title contains the expected error message' + assert thrown.title == path + ' not a valid path' + where: 'the following invalid paths are used' + scenario | path + 'no = After (last) class name'| 'ProvMnS/v1/someOtherClass=someId/myClass' + 'missing ProvMnS prefix' | 'v1/segment1/myClass=myId' + 'wrong version' | 'ProvMnS/wrongVersion/myClass=myId' + 'empty path' | '' } } diff --git a/cps-ncmp-service/src/test/groovy/org/onap/cps/ncmp/impl/provmns/ParametersBuilderSpec.groovy b/cps-ncmp-service/src/test/groovy/org/onap/cps/ncmp/impl/provmns/ParametersBuilderSpec.groovy index 8b81306822..12fac73f2e 100644 --- a/cps-ncmp-service/src/test/groovy/org/onap/cps/ncmp/impl/provmns/ParametersBuilderSpec.groovy +++ b/cps-ncmp-service/src/test/groovy/org/onap/cps/ncmp/impl/provmns/ParametersBuilderSpec.groovy @@ -28,19 +28,21 @@ class ParametersBuilderSpec extends Specification{ def objectUnderTest = new ParametersBuilder() - def 'Create url template parameters for GET'() { + def 'Create url template parameters for read operations (GET).'() { when: 'Creating URL parameters for GET with all possible items' def result = objectUnderTest.createUrlTemplateParametersForRead( - new Scope(scopeLevel: 1, scopeType: 'BASE_SUBTREE'), - 'my filter', - ['my attributes'], - ['my fields'], - new ClassNameIdGetDataNodeSelectorParameter(dataNodeSelector: 'my dataNodeSelector'), - new YangModelCmHandle(dmiServiceName: 'myDmiService'), - new RequestPathParameters(uriLdnFirstPart:'myPathVariable=myPathValue', className: 'myClassName', id:'myId')) + new YangModelCmHandle(dmiServiceName: 'myDmiService'), + '/target/fdn', + new Scope(scopeLevel: 1, scopeType: 'BASE_SUBTREE'), + 'my filter', + ['my attributes'], + ['my fields'], + new ClassNameIdGetDataNodeSelectorParameter(dataNodeSelector: 'my dataNodeSelector'), + + ) then: 'the template has the correct result' - assert result.urlTemplate.toString().startsWith('myDmiService/ProvMnS/v1/myPathVariable=myPathValue/myClassName=myId') - and: 'all url variable have been set correctly' + assert result.urlTemplate.toString().startsWith('myDmiService/ProvMnS/v1//target/fdn?') + and: 'all url variables have been set correctly' assert result.urlVariables.size() == 6 assert result.urlVariables.scopeLevel == '1' assert result.urlVariables.scopeType == 'BASE_SUBTREE' @@ -50,28 +52,27 @@ class ParametersBuilderSpec extends Specification{ assert result.urlVariables.dataNodeSelector == 'my dataNodeSelector' } - def 'Create url template parameters for GET without all optional parameters.'() { + def 'Create url template parameters for GET without any optional parameter.'() { when: 'Creating URL parameters for GET with null=values where possible' def result = objectUnderTest.createUrlTemplateParametersForRead( - new Scope(), - null, - null, - null, - new ClassNameIdGetDataNodeSelectorParameter(dataNodeSelector: 'my dataNodeSelector'), - new YangModelCmHandle(dmiServiceName: 'myDmiService'), - new RequestPathParameters(uriLdnFirstPart:'myPathVariable=myPathValue', className: 'myClassName', id:'myId')) + new YangModelCmHandle(dmiServiceName: 'myDmiService'), + 'some target fdn', + new Scope(), + null, + null, + null, + new ClassNameIdGetDataNodeSelectorParameter(dataNodeSelector: 'my dataNodeSelector'), + ) then: 'The url variables contains only a data node selector' assert result.urlVariables.size() == 1 assert result.urlVariables.keySet()[0] == 'dataNodeSelector' } - def 'Create url template parameters for PUT and PATCH.'() { + def 'Create url template parameters for write operations.'() { when: 'Creating URL parameters for PUT (or PATCH)' - def result = objectUnderTest.createUrlTemplateParametersForWrite( - new YangModelCmHandle(dmiServiceName: 'myDmiService'), - new RequestPathParameters(uriLdnFirstPart:'myPathVariable=myPathValue', className: 'myClassName', id:'myId')) + def result = objectUnderTest.createUrlTemplateParametersForWrite(new YangModelCmHandle(dmiServiceName: 'myDmiService'),'/target/fdn') then: 'the template has the correct correct' - assert result.urlTemplate.toString().startsWith('myDmiService/ProvMnS/v1/myPathVariable=myPathValue/myClassName=myId') + assert result.urlTemplate.toString().startsWith('myDmiService/ProvMnS/v1//target/fdn') and: 'no url variables have been set' assert result.urlVariables.isEmpty() } diff --git a/cps-ncmp-service/src/test/groovy/org/onap/cps/ncmp/impl/provmns/RequestParametersSpec.groovy b/cps-ncmp-service/src/test/groovy/org/onap/cps/ncmp/impl/provmns/RequestParametersSpec.groovy new file mode 100644 index 0000000000..fe8f186a52 --- /dev/null +++ b/cps-ncmp-service/src/test/groovy/org/onap/cps/ncmp/impl/provmns/RequestParametersSpec.groovy @@ -0,0 +1,42 @@ +/* + * ============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.ncmp.impl.provmns + +import spock.lang.Specification + +class RequestParametersSpec extends Specification { + + def objectUnderTest = new RequestParameters() + + def 'Generate target FDN #scenario.'() { + given: 'request parameters with URI LDN first part, class name and id' + objectUnderTest.uriLdnFirstPart = uriLdnFirstPart + objectUnderTest.className = 'myClass' + objectUnderTest.id = 'myId' + when: 'target FDN is generated' + def result = objectUnderTest.toTargetFdn() + then: 'the target FDN is as expected' + result == expectedTargetFdn + where: 'the following uri first part is used' + scenario | uriLdnFirstPart || expectedTargetFdn + 'with segments' | '/segment1' || '/segment1/myClass=myId' + 'empty first part' | '' || '/myClass=myId' + } +} diff --git a/cps-parent/pom.xml b/cps-parent/pom.xml index 2cbb8c3282..e23dd42893 100644 --- a/cps-parent/pom.xml +++ b/cps-parent/pom.xml @@ -67,7 +67,7 @@ ${project.build.directory}/code-coverage/jacoco-ut.exec ${project.reporting.outputDirectory}/jacoco-ut ${project.reporting.outputDirectory}/jacoco-aggregate - 1.00 + 0.10 ../jacoco-report/target/site/jacoco-aggregate/jacoco.xml diff --git a/docker-compose/config/nginx/nginx.conf b/docker-compose/config/nginx/nginx.conf index cb0d770ca4..ada5ad0132 100644 --- a/docker-compose/config/nginx/nginx.conf +++ b/docker-compose/config/nginx/nginx.conf @@ -22,7 +22,7 @@ http { upstream cps-and-ncmp { least_conn; server cps-and-ncmp-0:8080; - server cps-and-ncmp-1:8080; +# server cps-and-ncmp-1:8080; } # Set the max allowed size of the incoming request diff --git a/docker-compose/docker-compose.yml b/docker-compose/docker-compose.yml index a57539e261..635fe027fc 100644 --- a/docker-compose/docker-compose.yml +++ b/docker-compose/docker-compose.yml @@ -55,8 +55,8 @@ services: cps-and-ncmp-template: image: ${DOCKER_REPO:-nexus3.onap.org:10003}/onap/cps-and-ncmp:${CPS_VERSION:-latest} ### DEBUG: Uncomment next line to enable java debugging (ensure 'ports' aligns with 'deploy') - ### ports: - ### - ${CPS_CORE_DEBUG_PORT:-5005}:5005 + ports: + - ${CPS_CORE_DEBUG_PORT:-5005}:5005 environment: DB_HOST: ${DB_HOST:-dbpostgresql} DB_USERNAME: ${DB_USERNAME:-cps} @@ -71,9 +71,9 @@ services: POLICY_SERVICE_ENABLED: 'false' POLICY_SERVICE_DEFAULT_DECISION: 'deny from env' CPS_MONITORING_MICROMETER_JVM_EXTRAS: 'true' - JAVA_TOOL_OPTIONS: "-XX:InitialRAMPercentage=70.0 -XX:MaxRAMPercentage=70.0" + ###JAVA_TOOL_OPTIONS: "-XX:InitialRAMPercentage=70.0 -XX:MaxRAMPercentage=70.0" ### DEBUG: Uncomment next line to enable java debugging - ### JAVA_TOOL_OPTIONS: -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 + JAVA_TOOL_OPTIONS: -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 NCMP_INVENTORY_MODEL_UPGRADE_R20250722_ENABLED: 'false' restart: on-failure:3 deploy: @@ -104,18 +104,18 @@ services: condition: service_healthy ### DEBUG: For easier debugging use just 1 instance and comment out below - cps-and-ncmp-1: - extends: - service: cps-and-ncmp-template - container_name: ${CPS_INSTANCE_1_CONTAINER_NAME:-cps-and-ncmp-1} - deploy: - replicas: 1 - hostname: cps-ncmp-1 - ports: - - ${CPS_INSTANCE_1_REST_PORT:-8699}:8080 - depends_on: - dbpostgresql: - condition: service_healthy +# cps-and-ncmp-1: +# extends: +# service: cps-and-ncmp-template +# container_name: ${CPS_INSTANCE_1_CONTAINER_NAME:-cps-and-ncmp-1} +# deploy: +# replicas: 1 +# hostname: cps-ncmp-1 +# ports: +# - ${CPS_INSTANCE_1_REST_PORT:-8699}:8080 +# depends_on: +# dbpostgresql: +# condition: service_healthy nginx: container_name: ${NGINX_CONTAINER_NAME:-nginx-loadbalancer} @@ -125,7 +125,7 @@ services: depends_on: - cps-and-ncmp-0 ### DEBUG: For easier debugging use just 1 instance and comment out below - - cps-and-ncmp-1 +# - cps-and-ncmp-1 volumes: - ./config/nginx/nginx.conf:/etc/nginx/nginx.conf - ./config/nginx/proxy_params:/etc/nginx/proxy_params @@ -178,15 +178,17 @@ services: image: ${DOCKER_REPO:-nexus3.onap.org:10003}/onap/dmi-stub:${DMI_DEMO_STUB_VERSION:-1.8.0-SNAPSHOT} ports: - ${DMI_DEMO_STUB_PORT:-8784}:8092 + - 5006:5006 environment: KAFKA_BOOTSTRAP_SERVER: kafka:29092 NCMP_CONSUMER_GROUP_ID: ncmp-group NCMP_ASYNC_M2M_TOPIC: ncmp-async-m2m - MODULE_INITIAL_PROCESSING_DELAY_MS: 180000 + MODULE_INITIAL_PROCESSING_DELAY_MS: 180 MODULE_REFERENCES_DELAY_MS: 100 MODULE_RESOURCES_DELAY_MS: 1000 READ_DATA_FOR_CM_HANDLE_DELAY_MS: 300 WRITE_DATA_FOR_CM_HANDLE_DELAY_MS: 670 + JAVA_TOOL_OPTIONS: -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5006 restart: unless-stopped healthcheck: test: wget -q -O - http://localhost:8092/actuator/health/readiness | grep -q '{"status":"UP"}' || exit 1 -- 2.16.6