From ea04c07ad990b5543766e95e234cae746bd1fbc1 Mon Sep 17 00:00:00 2001 From: Renu Kumari Date: Tue, 10 Aug 2021 16:43:04 -0400 Subject: Validate controller input and provide expected response - validate endpoint input - set default values if it is missing in the optional fields - used hateoas for previous and next record link generation - use service layer in controller layer Issue-ID: CPS-449 Signed-off-by: Renu Kumari Change-Id: I7936cd9e8e7dead3b5650b421bb12f10d14ffa9b --- docs/api/swagger/openapi.yml | 16 +- pom.xml | 4 + .../event/model/CpsDataUpdatedEventMapper.java | 11 +- .../temporal/controller/rest/QueryController.java | 93 +++++++- .../controller/rest/QueryExceptionHandler.java | 73 +++++++ .../controller/rest/QueryResponseFactory.java | 170 +++++++++++++++ .../controller/rest/model/AnchorDetailsMapper.java | 38 ++++ .../temporal/controller/rest/model/SortMapper.java | 80 +++++++ .../temporal/controller/utils/DateTimeUtility.java | 40 ++++ .../onap/cps/temporal/domain/SearchCriteria.java | 56 ++++- .../temporal/service/NetworkDataServiceImpl.java | 10 +- src/main/resources/application.yml | 3 + .../model/CpsDataUpdatedEventMapperSpec.groovy | 2 +- .../rest/QueryControllerDataBuilder.groovy | 159 ++++++++++++++ .../controller/rest/QueryControllerSpec.groovy | 234 ++++++++++++++++++--- .../cps/temporal/domain/SearchCriteriaSpec.groovy | 33 ++- .../NetworkDataRepositoryImplSpec.groovy | 3 +- .../service/NetworkDataServiceImplSpec.groovy | 33 ++- src/test/resources/application.yml | 3 + 19 files changed, 998 insertions(+), 63 deletions(-) create mode 100644 src/main/java/org/onap/cps/temporal/controller/rest/QueryExceptionHandler.java create mode 100644 src/main/java/org/onap/cps/temporal/controller/rest/QueryResponseFactory.java create mode 100644 src/main/java/org/onap/cps/temporal/controller/rest/model/AnchorDetailsMapper.java create mode 100644 src/main/java/org/onap/cps/temporal/controller/rest/model/SortMapper.java create mode 100644 src/main/java/org/onap/cps/temporal/controller/utils/DateTimeUtility.java create mode 100644 src/test/groovy/org/onap/cps/temporal/controller/rest/QueryControllerDataBuilder.groovy diff --git a/docs/api/swagger/openapi.yml b/docs/api/swagger/openapi.yml index cde25a8..991d807 100644 --- a/docs/api/swagger/openapi.yml +++ b/docs/api/swagger/openapi.yml @@ -68,7 +68,7 @@ paths: nextRecordsLink: /v1/dataspace/my-dataspace/anchors/my-anchor/history?pageLimit=20&pageNumber=2 previousRecordsLink: /v1/dataspace/my-dataspace/anchors/my-anchor/history?pageLimit=20&pageNumber=0 records: - - timestamp: '2021-03-21T00:00:00.000000-0:00' + - timestamp: '2021-03-21T00:00:00.000-0000' dataspace: my-dataspace schemaSet: my-schema-set anchor: my-anchor @@ -114,7 +114,7 @@ paths: nextRecordsLink: /v1/dataspace/my-dataspace/anchors/history?pageLimit=20&pageNumber=2 previousRecordsLink: /v1/dataspace/my-dataspace/anchors/history?pageLimit=20&pageNumber=0 records: - - timestamp: '2021-03-21T00:00:00.000000-0:00' + - timestamp: '2021-03-21T00:00:00.000-0000' dataspace: my-dataspace schemaSet: my-schema-set anchor: my-anchor @@ -143,7 +143,7 @@ components: required: false schema: type: string - example: '2021-03-21T00:00:00.000000-0:00' + example: '2021-03-21T00:00:00.000-0000' simplePayloadFilter: name: simplePayloadFilter in: query @@ -158,7 +158,7 @@ components: required: false schema: type: string - example: '2021-03-21T00:00:00.000000-0:00' + example: '2021-03-21T00:00:00.000-0000' pageLimit: in: query name: pageLimit @@ -166,7 +166,6 @@ components: schema: type: integer minimum: 0 - maximum: 10000 default: 1000 description: The numbers of items to return pageNumber: @@ -185,7 +184,7 @@ components: schema: type: string default: observed_timestamp:desc - description: "Sort by timestamp in 'asc' or 'desc' order. Supported values:
timestamp:desc
timestamp:asc" + description: "Sort by timestamp in 'asc' or 'desc' order. Supported values:
observed_timestamp:desc
anchor:asc,observed_timestamp:desc" responses: BadRequest: description: Bad Request @@ -210,10 +209,9 @@ components: type: object title: AnchorDetails properties: - timestamp: + observedTimestamp: type: string - format: date-time - example: '2021-03-21T00:00:00.000000-0:00' + example: '2021-03-21T00:00:00.000-0000' dataspace: type: string example: 'my-dataspace' diff --git a/pom.xml b/pom.xml index 8427733..2975f92 100755 --- a/pom.xml +++ b/pom.xml @@ -116,6 +116,10 @@ org.springframework.boot spring-boot-starter-validation + + org.springframework.boot + spring-boot-starter-hateoas + org.mapstruct mapstruct diff --git a/src/main/java/org/onap/cps/temporal/controller/event/model/CpsDataUpdatedEventMapper.java b/src/main/java/org/onap/cps/temporal/controller/event/model/CpsDataUpdatedEventMapper.java index 9ef25d5..d180509 100644 --- a/src/main/java/org/onap/cps/temporal/controller/event/model/CpsDataUpdatedEventMapper.java +++ b/src/main/java/org/onap/cps/temporal/controller/event/model/CpsDataUpdatedEventMapper.java @@ -13,6 +13,8 @@ * 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========================================================= */ @@ -21,11 +23,11 @@ package org.onap.cps.temporal.controller.event.model; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import java.time.OffsetDateTime; -import java.time.format.DateTimeFormatter; import org.mapstruct.Mapper; import org.mapstruct.Mapping; import org.onap.cps.event.model.CpsDataUpdatedEvent; import org.onap.cps.event.model.Data; +import org.onap.cps.temporal.controller.utils.DateTimeUtility; import org.onap.cps.temporal.domain.NetworkData; /** @@ -34,8 +36,7 @@ import org.onap.cps.temporal.domain.NetworkData; @Mapper(componentModel = "spring") public abstract class CpsDataUpdatedEventMapper { - private static final DateTimeFormatter ISO_TIMESTAMP_FORMATTER = - DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSZ"); + private ObjectMapper objectMapper = new ObjectMapper(); @Mapping(source = "content.observedTimestamp", target = "observedTimestamp") @Mapping(source = "content.dataspaceName", target = "dataspace") @@ -46,11 +47,11 @@ public abstract class CpsDataUpdatedEventMapper { public abstract NetworkData eventToEntity(CpsDataUpdatedEvent cpsDataUpdatedEvent); String map(final Data data) throws JsonProcessingException { - return data != null ? new ObjectMapper().writeValueAsString(data) : null; + return data != null ? objectMapper.writeValueAsString(data) : null; } OffsetDateTime map(final String timestamp) { - return timestamp != null ? OffsetDateTime.parse(timestamp, ISO_TIMESTAMP_FORMATTER) : null; + return DateTimeUtility.toOffsetDateTime(timestamp); } } diff --git a/src/main/java/org/onap/cps/temporal/controller/rest/QueryController.java b/src/main/java/org/onap/cps/temporal/controller/rest/QueryController.java index e7171a0..ab29e19 100644 --- a/src/main/java/org/onap/cps/temporal/controller/rest/QueryController.java +++ b/src/main/java/org/onap/cps/temporal/controller/rest/QueryController.java @@ -20,12 +20,19 @@ package org.onap.cps.temporal.controller.rest; +import java.time.OffsetDateTime; import javax.validation.Valid; -import javax.validation.constraints.Max; +import javax.validation.ValidationException; import javax.validation.constraints.Min; import javax.validation.constraints.NotNull; +import org.apache.commons.lang3.StringUtils; import org.onap.cps.temporal.controller.rest.model.AnchorHistory; -import org.springframework.http.HttpStatus; +import org.onap.cps.temporal.controller.rest.model.SortMapper; +import org.onap.cps.temporal.controller.utils.DateTimeUtility; +import org.onap.cps.temporal.domain.NetworkData; +import org.onap.cps.temporal.domain.SearchCriteria; +import org.onap.cps.temporal.service.NetworkDataService; +import org.springframework.data.domain.Slice; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -34,19 +41,89 @@ import org.springframework.web.bind.annotation.RestController; @RequestMapping("${rest.api.base-path}") public class QueryController implements CpsTemporalQueryApi { + private NetworkDataService networkDataService; + private SortMapper sortMapper; + private QueryResponseFactory queryResponseFactory; + + /** + * Constructor. + * + * @param networkDataService networkDataService + * @param sortMapper sortMapper + * @param queryResponseFactory anchorHistoryResponseFactory + */ + public QueryController(final NetworkDataService networkDataService, + final SortMapper sortMapper, + final QueryResponseFactory queryResponseFactory) { + this.networkDataService = networkDataService; + this.sortMapper = sortMapper; + this.queryResponseFactory = queryResponseFactory; + } + @Override public ResponseEntity getAnchorDataByName(final String dataspaceName, - final String anchorName, final @Valid String after, final @Valid String simplePayloadFilter, + final String anchorName, final @Valid String observedTimestampAfter, + final @Valid String simplePayloadFilter, final @Valid String pointInTime, final @Min(0) @Valid Integer pageNumber, - final @Min(0) @Max(10000) @Valid Integer pageLimit, final @Valid String sort) { - return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); + final @Min(0) @Valid Integer pageLimit, final @Valid String sortAsString) { + + final var searchCriteriaBuilder = + getSearchCriteriaBuilder(observedTimestampAfter, simplePayloadFilter, pointInTime, + pageNumber, pageLimit, sortAsString) + .dataspaceName(dataspaceName).anchorName(anchorName); + final var searchCriteria = searchCriteriaBuilder.build(); + final Slice searchResult = networkDataService.searchNetworkData(searchCriteria); + final var anchorHistory = queryResponseFactory + .createAnchorDataByNameResponse(searchCriteria, searchResult); + return ResponseEntity.ok(anchorHistory); } @Override public ResponseEntity getAnchorsDataByFilter(final String dataspaceName, - final @NotNull @Valid String schemaSetName, final @Valid String after, final @Valid String simplePayloadFilter, + final @NotNull @Valid String schemaSetName, final @Valid String observedTimestampAfter, + final @Valid String simplePayloadFilter, final @Valid String pointInTime, final @Min(0) @Valid Integer pageNumber, - final @Min(0) @Max(10000) @Valid Integer pageLimit, final @Valid String sort) { - return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); + final @Min(0) @Valid Integer pageLimit, final @Valid String sortAsString) { + final var searchCriteriaBuilder = + getSearchCriteriaBuilder(observedTimestampAfter, + simplePayloadFilter, + pointInTime, pageNumber, + pageLimit, sortAsString) + .dataspaceName(dataspaceName).schemaSetName(schemaSetName); + final var searchCriteria = searchCriteriaBuilder.build(); + final Slice searchResult = networkDataService.searchNetworkData(searchCriteria); + final var anchorHistory = queryResponseFactory + .createAnchorsDataByFilterResponse(searchCriteria, searchResult); + return ResponseEntity.ok(anchorHistory); + } + + private SearchCriteria.Builder getSearchCriteriaBuilder(final String observedTimestampAfter, + final String simplePayloadFilter, + final String pointInTime, final Integer pageNumber, + final Integer pageLimit, final String sortAsString) { + + final var searchCriteriaBuilder = SearchCriteria.builder() + .pagination(pageNumber, pageLimit) + .observedAfter(getOffsetDateTime(observedTimestampAfter, "observedTimestampAfter")) + .simplePayloadFilter(simplePayloadFilter) + .sort(sortMapper.toSort(sortAsString)); + + if (!StringUtils.isEmpty(pointInTime)) { + searchCriteriaBuilder.createdBefore(getOffsetDateTime(pointInTime, "pointInTime")); + } + + return searchCriteriaBuilder; + } + + private OffsetDateTime getOffsetDateTime(final String datetime, final String propertyName) { + try { + return DateTimeUtility.toOffsetDateTime(datetime); + } catch (final Exception exception) { + throw new ValidationException( + String.format("%s must be in '%s' format", propertyName, DateTimeUtility.ISO_TIMESTAMP_PATTERN)); + } + } + + } diff --git a/src/main/java/org/onap/cps/temporal/controller/rest/QueryExceptionHandler.java b/src/main/java/org/onap/cps/temporal/controller/rest/QueryExceptionHandler.java new file mode 100644 index 0000000..d620dbe --- /dev/null +++ b/src/main/java/org/onap/cps/temporal/controller/rest/QueryExceptionHandler.java @@ -0,0 +1,73 @@ +/* + * ============LICENSE_START======================================================= + * Copyright (c) 2021 Bell Canada. + * ================================================================================ + * 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.temporal.controller.rest; + +import javax.validation.ValidationException; +import lombok.extern.slf4j.Slf4j; +import org.onap.cps.temporal.controller.rest.model.ErrorMessage; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@Slf4j +@RestControllerAdvice(basePackageClasses = QueryController.class) +public class QueryExceptionHandler { + + @ExceptionHandler({ValidationException.class}) + public ResponseEntity handleClientError(final ValidationException validationException) { + return buildErrorMessage(HttpStatus.BAD_REQUEST, validationException); + } + + @ExceptionHandler({IllegalArgumentException.class}) + public ResponseEntity handleClientError(final IllegalArgumentException illegalArgumentException) { + return logAndBuildErrorMessage(HttpStatus.BAD_REQUEST, illegalArgumentException); + } + + @ExceptionHandler + public ResponseEntity handleInternalServerError(final Exception exception) { + return logAndBuildErrorMessage(HttpStatus.INTERNAL_SERVER_ERROR, exception); + } + + private ResponseEntity logAndBuildErrorMessage(final HttpStatus httpStatus, + final Exception exception) { + logException(exception); + return buildErrorMessage(httpStatus, exception); + } + + private void logException(final Exception exception) { + final var message = String.format("Failed to process : %s. Error cause is %s", + exception.getMessage(), + exception.getCause() != null ? exception.getCause().toString() : null); + log.error(message, exception); + + } + + private ResponseEntity buildErrorMessage(final HttpStatus httpStatus, + final Exception exception) { + final var errorMessage = new ErrorMessage(); + errorMessage.setStatus(Integer.toString(httpStatus.value())); + errorMessage.setMessage(exception.getMessage()); + return ResponseEntity.status(httpStatus).body(errorMessage); + } + + +} diff --git a/src/main/java/org/onap/cps/temporal/controller/rest/QueryResponseFactory.java b/src/main/java/org/onap/cps/temporal/controller/rest/QueryResponseFactory.java new file mode 100644 index 0000000..6ac4759 --- /dev/null +++ b/src/main/java/org/onap/cps/temporal/controller/rest/QueryResponseFactory.java @@ -0,0 +1,170 @@ +/* + * ============LICENSE_START======================================================= + * Copyright (c) 2021 Bell Canada. + * ================================================================================ + * 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.temporal.controller.rest; + +import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo; +import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.methodOn; + +import java.util.List; +import java.util.stream.Collectors; +import org.onap.cps.temporal.controller.rest.model.AnchorDetails; +import org.onap.cps.temporal.controller.rest.model.AnchorDetailsMapper; +import org.onap.cps.temporal.controller.rest.model.AnchorHistory; +import org.onap.cps.temporal.controller.rest.model.SortMapper; +import org.onap.cps.temporal.controller.utils.DateTimeUtility; +import org.onap.cps.temporal.domain.NetworkData; +import org.onap.cps.temporal.domain.SearchCriteria; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; + +@Component +public class QueryResponseFactory { + + private SortMapper sortMapper; + private String basePath; + private AnchorDetailsMapper anchorDetailsMapper; + + /** + * Constructor. + * + * @param sortMapper sortMapper + * @param anchorDetailsMapper anchorDetailsMapper + * @param basePath basePath + */ + public QueryResponseFactory(final SortMapper sortMapper, + final AnchorDetailsMapper anchorDetailsMapper, + @Value("${rest.api.base-path}") final String basePath) { + this.sortMapper = sortMapper; + this.anchorDetailsMapper = anchorDetailsMapper; + this.basePath = basePath; + } + + AnchorHistory createAnchorsDataByFilterResponse(final SearchCriteria searchCriteria, + final Slice response) { + + final var anchorHistory = new AnchorHistory(); + if (response.hasNext()) { + anchorHistory.setNextRecordsLink( + toRelativeLink(getAbsoluteLinkForGetAnchorsDataByFilter(searchCriteria, response.nextPageable()))); + } + if (response.hasPrevious()) { + anchorHistory.setPreviousRecordsLink( + toRelativeLink( + getAbsoluteLinkForGetAnchorsDataByFilter(searchCriteria, response.previousPageable()))); + } + anchorHistory.setRecords(convertToAnchorDetails(response.getContent())); + return anchorHistory; + } + + AnchorHistory createAnchorDataByNameResponse(final SearchCriteria searchCriteria, + final Slice response) { + + final var anchorHistory = new AnchorHistory(); + if (response.hasNext()) { + anchorHistory.setNextRecordsLink(toRelativeLink( + getAbsoluteLinkForGetAnchorDataByName(searchCriteria, response.nextPageable()))); + } + if (response.hasPrevious()) { + anchorHistory.setPreviousRecordsLink(toRelativeLink( + getAbsoluteLinkForGetAnchorDataByName(searchCriteria, response.previousPageable()))); + } + anchorHistory.setRecords(convertToAnchorDetails(response.getContent())); + return anchorHistory; + } + + private List convertToAnchorDetails(final List networkDataList) { + return networkDataList.stream() + .map(networkData -> anchorDetailsMapper.toAnchorDetails(networkData)) + .collect(Collectors.toList()); + } + + /* + Spring hateoas only provides absolute link. But in the microservices, relative links will be more appropriate + */ + private String toRelativeLink(final String absoluteLink) { + + /* Spring hateoas Issue: + It does replace the variable defined at the Controller level, + so we are removing the variable name and replace it with basePath. + https://github.com/spring-projects/spring-hateoas/issues/361 + https://github.com/spring-projects/spring-hateoas/pull/1375 + */ + final int contextPathBeginIndex = absoluteLink.indexOf("rest.api.base-path%257D"); + return basePath + absoluteLink.substring(contextPathBeginIndex + 23); + } + + private String getAbsoluteLinkForGetAnchorDataByName(final SearchCriteria searchCriteria, + final Pageable pageable) { + final var uriComponentsBuilder = linkTo(methodOn(QueryController.class).getAnchorDataByName( + searchCriteria.getDataspaceName(), + searchCriteria.getAnchorName(), + DateTimeUtility.toString(searchCriteria.getObservedAfter()), + null, + DateTimeUtility.toString(searchCriteria.getCreatedBefore()), + pageable.getPageNumber(), pageable.getPageSize(), + sortMapper.sortAsString(searchCriteria.getPageable().getSort()))) + .toUriComponentsBuilder(); + addSimplePayloadFilter(uriComponentsBuilder, searchCriteria.getSimplePayloadFilter()); + return encodePlusSign(uriComponentsBuilder.toUriString()); + } + + private String getAbsoluteLinkForGetAnchorsDataByFilter(final SearchCriteria searchCriteria, + final Pageable pageable) { + final var uriComponentsBuilder = linkTo(methodOn(QueryController.class).getAnchorsDataByFilter( + searchCriteria.getDataspaceName(), + searchCriteria.getSchemaSetName(), + DateTimeUtility.toString(searchCriteria.getObservedAfter()), + null, + DateTimeUtility.toString(searchCriteria.getCreatedBefore()), + pageable.getPageNumber(), pageable.getPageSize(), + sortMapper.sortAsString(searchCriteria.getPageable().getSort()))) + .toUriComponentsBuilder(); + addSimplePayloadFilter(uriComponentsBuilder, searchCriteria.getSimplePayloadFilter()); + return encodePlusSign(uriComponentsBuilder.toUriString()); + } + + /* + Spring hateoas does double encoding when generting URI. + To avoid it in the case of simplePayloadFilter, + the 'simplePayloadFilter is being added explicitly to UriComponentsBuilder + */ + private UriComponentsBuilder addSimplePayloadFilter(final UriComponentsBuilder uriComponentsBuilder, + final String simplePayloadFilter) { + if (simplePayloadFilter != null) { + uriComponentsBuilder.queryParam("simplePayloadFilter", simplePayloadFilter); + } + return uriComponentsBuilder; + } + + /* + Spring hateoas does not encode '+' in the query param but it deccodes '+' as space. + Due to this inconsistency, API was failing to convert datetime with positive timezone. + The fix is done in the spring-hateoas 1.4 version but it is yet to release. + As a workaround, we are replacing all the '+' with '%2B' + https://github.com/spring-projects/spring-hateoas/issues/1485 + */ + private String encodePlusSign(final String link) { + return link.replace("+", "%2B"); + } +} diff --git a/src/main/java/org/onap/cps/temporal/controller/rest/model/AnchorDetailsMapper.java b/src/main/java/org/onap/cps/temporal/controller/rest/model/AnchorDetailsMapper.java new file mode 100644 index 0000000..1c44c36 --- /dev/null +++ b/src/main/java/org/onap/cps/temporal/controller/rest/model/AnchorDetailsMapper.java @@ -0,0 +1,38 @@ +/* + * ============LICENSE_START======================================================= + * Copyright (c) 2021 Bell Canada. + * ================================================================================ + * 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.temporal.controller.rest.model; + +import java.time.OffsetDateTime; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.onap.cps.temporal.controller.utils.DateTimeUtility; +import org.onap.cps.temporal.domain.NetworkData; + +@Mapper(componentModel = "spring") +public interface AnchorDetailsMapper { + + @Mapping(source = "payload", target = "data") + AnchorDetails toAnchorDetails(NetworkData networkData); + + default String map(final OffsetDateTime timestamp) { + return DateTimeUtility.toString(timestamp); + } +} diff --git a/src/main/java/org/onap/cps/temporal/controller/rest/model/SortMapper.java b/src/main/java/org/onap/cps/temporal/controller/rest/model/SortMapper.java new file mode 100644 index 0000000..cd553eb --- /dev/null +++ b/src/main/java/org/onap/cps/temporal/controller/rest/model/SortMapper.java @@ -0,0 +1,80 @@ +/* + * ============LICENSE_START======================================================= + * Copyright (c) 2021 Bell Canada. + * ================================================================================ + * 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.temporal.controller.rest.model; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.stream.Collectors; +import javax.validation.ValidationException; +import javax.validation.constraints.NotEmpty; +import org.springframework.data.domain.Sort; +import org.springframework.data.domain.Sort.Direction; +import org.springframework.data.domain.Sort.Order; +import org.springframework.stereotype.Component; + +@Component +public class SortMapper { + + private static final String SORT_ORDER_SEPARATOR = ","; + private static final String FIELD_DIRECTION_SEPARATOR = ":"; + + /** + * convert from Sort to String format "fieldname:direction,...,fieldname:direction". + * + * @param sort sort + * @return sort string + */ + public String sortAsString(final Sort sort) { + return sort.stream() + .map(sortOrder -> + sortOrder.getProperty() + FIELD_DIRECTION_SEPARATOR + + sortOrder.getDirection().toString().toLowerCase(Locale.ENGLISH)) + .collect(Collectors.joining(SORT_ORDER_SEPARATOR)); + } + + /** + * Convert from "fieldname:direction,...,fieldname:direction" format to Sort. Example : + * "anchor:asc,observed_timestamp:desc" + * + * @param sortString sortString + * @return Sort + */ + public Sort toSort(@NotEmpty final String sortString) { + try { + final String[] sortingOrderAsString = sortString.split(SORT_ORDER_SEPARATOR); + final List sortOrder = new ArrayList<>(); + for (final String eachSortAsString : sortingOrderAsString) { + final String[] eachSortDetail = eachSortAsString.split(FIELD_DIRECTION_SEPARATOR); + final var direction = Direction.fromString(eachSortDetail[1]); + final var fieldName = eachSortDetail[0]; + sortOrder.add(new Order(direction, fieldName)); + } + return Sort.by(sortOrder); + } catch (final Exception exception) { + throw new ValidationException( + String.format( + "Invalid sort format. sort '%s' is not in ':,...,:'" + + " format. Example: 'anchor:asc,observed_timestamp:desc'", sortString), exception + ); + } + } +} diff --git a/src/main/java/org/onap/cps/temporal/controller/utils/DateTimeUtility.java b/src/main/java/org/onap/cps/temporal/controller/utils/DateTimeUtility.java new file mode 100644 index 0000000..b36904e --- /dev/null +++ b/src/main/java/org/onap/cps/temporal/controller/utils/DateTimeUtility.java @@ -0,0 +1,40 @@ +/* + * ============LICENSE_START======================================================= + * Copyright (c) 2021 Bell Canada. + * ================================================================================ + * 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.temporal.controller.utils; + +import java.time.OffsetDateTime; +import java.time.format.DateTimeFormatter; +import org.apache.commons.lang3.StringUtils; + +public interface DateTimeUtility { + + String ISO_TIMESTAMP_PATTERN = "yyyy-MM-dd'T'HH:mm:ss.SSSZ"; + DateTimeFormatter ISO_TIMESTAMP_FORMATTER = DateTimeFormatter.ofPattern(ISO_TIMESTAMP_PATTERN); + + static OffsetDateTime toOffsetDateTime(String datetTimestampAsString) { + return StringUtils.isEmpty(datetTimestampAsString) + ? null : OffsetDateTime.parse(datetTimestampAsString, ISO_TIMESTAMP_FORMATTER); + } + + static String toString(OffsetDateTime offsetDateTime) { + return offsetDateTime != null ? ISO_TIMESTAMP_FORMATTER.format(offsetDateTime) : null; + } +} diff --git a/src/main/java/org/onap/cps/temporal/domain/SearchCriteria.java b/src/main/java/org/onap/cps/temporal/domain/SearchCriteria.java index 8188d84..4cd6a20 100644 --- a/src/main/java/org/onap/cps/temporal/domain/SearchCriteria.java +++ b/src/main/java/org/onap/cps/temporal/domain/SearchCriteria.java @@ -20,10 +20,14 @@ package org.onap.cps.temporal.domain; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; import java.time.OffsetDateTime; -import javax.validation.constraints.NotNull; +import java.util.List; import lombok.AccessLevel; import lombok.Builder; +import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.NoArgsConstructor; import org.apache.commons.lang3.StringUtils; @@ -31,10 +35,12 @@ import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort.Direction; +import org.springframework.data.domain.Sort.Order; @Getter @NoArgsConstructor(access = AccessLevel.PRIVATE) @Builder(builderClassName = "Builder") +@EqualsAndHashCode public class SearchCriteria { private OffsetDateTime createdBefore; @@ -47,7 +53,14 @@ public class SearchCriteria { public static class Builder { - private Sort sort = Sort.by(Direction.DESC, "observed_timestamp"); + private static final String OBSERVED_TIMESTAMP_FIELD_NAME = "observed_timestamp"; + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final List REQUIRED_SORT_ORDERS = List.of(Order.desc(OBSERVED_TIMESTAMP_FIELD_NAME)); + private static final List SUPPORTED_SORT_ORDERS = List.of(Order.desc(OBSERVED_TIMESTAMP_FIELD_NAME), + Order.asc("anchor")); + + private Sort sort = Sort.by(Direction.DESC, OBSERVED_TIMESTAMP_FIELD_NAME); private OffsetDateTime createdBefore = OffsetDateTime.now(); public Builder pagination(final int pageNumber, final int pageSize) { @@ -55,7 +68,44 @@ public class SearchCriteria { return this; } - public Builder sort(final @NotNull Sort sort) { + /** + * Validate that simplePayloadFilter is a valid json. + * + * @param simplePayloadFilter simplePayloadFilter + * @return Builder + */ + public Builder simplePayloadFilter(final String simplePayloadFilter) { + if (!StringUtils.isEmpty(simplePayloadFilter)) { + try { + OBJECT_MAPPER.readValue(simplePayloadFilter, ObjectNode.class); + this.simplePayloadFilter = simplePayloadFilter; + } catch (final JsonProcessingException jsonProcessingException) { + throw new IllegalArgumentException("simplePayloadFilter must be a valid json"); + } + } + return this; + } + + /** + * Validates the input with the expected list and saves only if matches. + * + * @param sort sort + * @return Builder builder + */ + public Builder sort(final Sort sort) { + if (sort == null) { + throw new IllegalArgumentException("sort must not be null"); + } + final List sortOrders = sort.toList(); + if (!SUPPORTED_SORT_ORDERS.containsAll(sortOrders)) { + throw new IllegalArgumentException( + "Invalid sorting. Supported sorts are " + SUPPORTED_SORT_ORDERS.toString()); + } + if (!sortOrders.containsAll(REQUIRED_SORT_ORDERS)) { + throw new IllegalArgumentException( + "Missing mandatory sort. Required sorts are " + REQUIRED_SORT_ORDERS.toString()); + } + this.sort = sort; return this; } diff --git a/src/main/java/org/onap/cps/temporal/service/NetworkDataServiceImpl.java b/src/main/java/org/onap/cps/temporal/service/NetworkDataServiceImpl.java index 7c2f999..3eba6fb 100644 --- a/src/main/java/org/onap/cps/temporal/service/NetworkDataServiceImpl.java +++ b/src/main/java/org/onap/cps/temporal/service/NetworkDataServiceImpl.java @@ -21,11 +21,13 @@ package org.onap.cps.temporal.service; import java.util.Optional; +import javax.validation.ValidationException; import lombok.extern.slf4j.Slf4j; import org.onap.cps.temporal.domain.NetworkData; import org.onap.cps.temporal.domain.NetworkDataId; import org.onap.cps.temporal.domain.SearchCriteria; import org.onap.cps.temporal.repository.NetworkDataRepository; +import org.springframework.beans.factory.annotation.Value; import org.springframework.data.domain.Slice; import org.springframework.stereotype.Service; @@ -37,9 +39,12 @@ import org.springframework.stereotype.Service; public class NetworkDataServiceImpl implements NetworkDataService { private final NetworkDataRepository networkDataRepository; + private final int maxPageSize; - public NetworkDataServiceImpl(final NetworkDataRepository networkDataRepository) { + public NetworkDataServiceImpl(final NetworkDataRepository networkDataRepository, + final @Value("${app.query.response.max-page-size}") int maxPageSize) { this.networkDataRepository = networkDataRepository; + this.maxPageSize = maxPageSize; } @Override @@ -59,6 +64,9 @@ public class NetworkDataServiceImpl implements NetworkDataService { @Override public Slice searchNetworkData(final SearchCriteria searchCriteria) { + if (searchCriteria.getPageable().getPageSize() > maxPageSize) { + throw new ValidationException("page-size must be less than or equals to " + maxPageSize); + } return networkDataRepository.findBySearchCriteria(searchCriteria); } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index c79351a..41eddf8 100755 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -57,6 +57,9 @@ app: listener: data-updated: topic: ${CPS_CHANGE_EVENT_TOPIC:cps.cfg-state-events} + query: + response: + max-page-size: 10000 springdoc: swagger-ui: diff --git a/src/test/groovy/org/onap/cps/temporal/controller/event/model/CpsDataUpdatedEventMapperSpec.groovy b/src/test/groovy/org/onap/cps/temporal/controller/event/model/CpsDataUpdatedEventMapperSpec.groovy index 132ff6d..a51c4fe 100644 --- a/src/test/groovy/org/onap/cps/temporal/controller/event/model/CpsDataUpdatedEventMapperSpec.groovy +++ b/src/test/groovy/org/onap/cps/temporal/controller/event/model/CpsDataUpdatedEventMapperSpec.groovy @@ -110,7 +110,7 @@ class CpsDataUpdatedEventMapperSpec extends Specification { result != null and: 'all result entity properties are the ones from the event' result.getObservedTimestamp() == - OffsetDateTime.parse(event.getContent().getObservedTimestamp(), isoTimestampFormatter) + OffsetDateTime.parse(event.getContent().getObservedTimestamp(), isoTimestampFormatter) result.getDataspace() == event.getContent().getDataspaceName() result.getSchemaSet() == event.getContent().getSchemaSetName() result.getAnchor() == event.getContent().getAnchorName() diff --git a/src/test/groovy/org/onap/cps/temporal/controller/rest/QueryControllerDataBuilder.groovy b/src/test/groovy/org/onap/cps/temporal/controller/rest/QueryControllerDataBuilder.groovy new file mode 100644 index 0000000..dee1e06 --- /dev/null +++ b/src/test/groovy/org/onap/cps/temporal/controller/rest/QueryControllerDataBuilder.groovy @@ -0,0 +1,159 @@ +/* + * ============LICENSE_START======================================================= + * Copyright (c) 2021 Bell Canada. + * ================================================================================ + * 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.temporal.controller.rest + +import org.onap.cps.temporal.controller.utils.DateTimeUtility +import org.onap.cps.temporal.domain.SearchCriteria +import org.springframework.data.domain.Sort +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders +import org.springframework.util.CollectionUtils +import org.springframework.util.MultiValueMap +import org.springframework.web.util.DefaultUriBuilderFactory +import org.springframework.web.util.UriComponentsBuilder +import org.springframework.web.util.UriUtils + +import java.nio.charset.Charset +import java.time.OffsetDateTime + +/* +To create objects required for the test based on same input + */ + +class QueryControllerDataBuilder { + + private static String POINT_IN_TIME_QUERY_PARAM = 'pointInTime' + private static String OBSERVED_TIMESTAMP_AFTER_QUERY_PARAM = 'observedTimestampAfter' + private static String PAGE_NUMBER_QUERY_PARAM = 'pageNumber' + private static String PAGE_LIMIT_QUERY_PARAM = 'pageLimit' + private static String SORT_QUERY_PARAM = 'sort' + private static String SIMPLE_PAYLOAD_FILTER_QUERY_PARAM = 'simplePayloadFilter' + + private static int DEFAULT_PAGE_NUMBER = 0 + private static int DEFAULT_PAGE_SIZE = 1000 + private static String DEFAULT_SORT = 'observed_timestamp:desc' + + private static Map SORT_MAP = ['anchor:asc' : Sort.by(Sort.Direction.ASC, 'anchor'), + 'observed_timestamp:desc': Sort.by(Sort.Direction.DESC, 'observed_timestamp')] + private static Map URI_MAP = + ['anchor by name' : '/cps-temporal/api/v1/dataspaces/{dataspace}/anchors/{anchor}/history', + 'anchors by schemaset': '/cps-temporal/api/v1/dataspaces/{dataspace}/anchors/history?schema-set-name={schemaSet}'] + + Map parameters + String endpoint + + QueryControllerDataBuilder(final String endPointName, final Map parameters) { + this.parameters = parameters + def replacements = ['{dataspace}': parameters.dataspace, + '{schemaSet}': parameters.schemaSet, + '{anchor}' : parameters.anchor] + endpoint = URI_MAP.get(endPointName).replace(replacements) + } + + MockHttpServletRequestBuilder createMockHttpRequestBuilder() { + def requestBuilder = MockMvcRequestBuilders.get(endpoint) + if (parameters.pointInTime != null) + requestBuilder.queryParam(POINT_IN_TIME_QUERY_PARAM, parameters.pointInTime) + if (parameters.observedTimestampAfter != null) + requestBuilder.queryParam(OBSERVED_TIMESTAMP_AFTER_QUERY_PARAM, parameters.observedTimestampAfter) + if (parameters.pageNumber != null) + requestBuilder.queryParam(PAGE_NUMBER_QUERY_PARAM, parameters.pageNumber.toString()) + if (parameters.pageLimit != null) + requestBuilder.queryParam(PAGE_LIMIT_QUERY_PARAM, parameters.pageLimit.toString()) + if (parameters.sortAsString != null) + requestBuilder.queryParam(SORT_QUERY_PARAM, parameters.sortAsString) + if (parameters.payloadFilter != null) + requestBuilder.queryParam(SIMPLE_PAYLOAD_FILTER_QUERY_PARAM, parameters.payloadFilter) + return requestBuilder.contentType(MediaType.APPLICATION_JSON) + } + + SearchCriteria.Builder createSearchCriteriaBuilder() { + def searchCriteriaBuilder = SearchCriteria.builder() + searchCriteriaBuilder.dataspaceName(parameters.dataspace) + .anchorName(parameters.anchor) + .schemaSetName(parameters.schemaSet) + if (parameters.pointInTime != null) + searchCriteriaBuilder.createdBefore(DateTimeUtility.toOffsetDateTime(parameters.pointInTime)) + if (parameters.observedTimestampAfter != null) + searchCriteriaBuilder.observedAfter(DateTimeUtility.toOffsetDateTime(parameters.observedTimestampAfter)) + if (parameters.pageNumber != null) + searchCriteriaBuilder.pagination(parameters.pageNumber, parameters.pageLimit) + if (parameters.payloadFilter != null) + searchCriteriaBuilder.simplePayloadFilter(parameters.payloadFilter) + if (parameters.sortAsString != null) + searchCriteriaBuilder.sort(SORT_MAP.get(((String) parameters.sortAsString).toLowerCase())) + return searchCriteriaBuilder + } + + private int getPageNumber() { + return parameters.pageNumber == null ? + DEFAULT_PAGE_NUMBER : + parameters.pageNumber + } + + void isExpectedNextRecordsLink(String actualNextLink) { + isExpectedLink(getPageNumber() + 1, actualNextLink) + } + + void isExpectedPreviousRecordsLink(String actualNextLink) { + isExpectedLink(getPageNumber() - 1, actualNextLink) + } + + void isExpectedLink(int pageNumber, String actualLink) { + def actualUriComponents = UriComponentsBuilder.fromUriString(actualLink).build() + def actualQueryParams = actualUriComponents.getQueryParams() + + if (parameters.observedTimestampAfter != null) { + validateQueryParam(OBSERVED_TIMESTAMP_AFTER_QUERY_PARAM, parameters.observedTimestampAfter, actualQueryParams) + } + if (parameters.payloadFilter != null) { + validateQueryParam(SIMPLE_PAYLOAD_FILTER_QUERY_PARAM, parameters.payloadFilter, actualQueryParams) + } + validatePointInTime(actualQueryParams) + validateQueryParam(PAGE_NUMBER_QUERY_PARAM, Integer.toString(pageNumber), actualQueryParams) + validateQueryParam(PAGE_LIMIT_QUERY_PARAM, + Integer.toString(parameters.pageLimit == null ? DEFAULT_PAGE_SIZE : parameters.pageLimit), actualQueryParams) + validateQueryParam(SORT_QUERY_PARAM, + parameters.sortAsString == null ? DEFAULT_SORT : parameters.sortAsString, actualQueryParams) + + } + + private void validateQueryParam(String paramName, Object expectedValue, MultiValueMap queryParams) { + def values = queryParams.get(paramName) + assert (!CollectionUtils.isEmpty(values)) + assert (expectedValue == URLDecoder.decode(values.get(0), Charset.defaultCharset())) + } + + boolean validatePointInTime(MultiValueMap queryParams) { + + def values = queryParams.get(POINT_IN_TIME_QUERY_PARAM) + assert (!CollectionUtils.isEmpty(values)) + def actualValue = URLDecoder.decode(values.get(0), Charset.defaultCharset()) + + if (parameters.pointInTime == null) { + assert DateTimeUtility.toOffsetDateTime(actualValue).isAfter(OffsetDateTime.now().minusMinutes(2)) + } else { + assert parameters.pointInTime == actualValue + } + } + +} diff --git a/src/test/groovy/org/onap/cps/temporal/controller/rest/QueryControllerSpec.groovy b/src/test/groovy/org/onap/cps/temporal/controller/rest/QueryControllerSpec.groovy index 771a3fc..a18a134 100644 --- a/src/test/groovy/org/onap/cps/temporal/controller/rest/QueryControllerSpec.groovy +++ b/src/test/groovy/org/onap/cps/temporal/controller/rest/QueryControllerSpec.groovy @@ -20,59 +20,237 @@ package org.onap.cps.temporal.controller.rest +import org.onap.cps.temporal.controller.utils.DateTimeUtility +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders + +import java.time.OffsetDateTime +import org.onap.cps.temporal.controller.rest.model.AnchorDetails +import org.onap.cps.temporal.controller.rest.model.AnchorDetailsMapperImpl +import org.onap.cps.temporal.controller.rest.model.AnchorHistory +import org.onap.cps.temporal.controller.rest.model.ErrorMessage +import org.onap.cps.temporal.controller.rest.model.SortMapper +import org.onap.cps.temporal.domain.NetworkData +import org.onap.cps.temporal.domain.SearchCriteria +import org.onap.cps.temporal.service.NetworkDataService +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.context.annotation.Import +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.SliceImpl +import org.springframework.data.domain.Sort import org.springframework.http.HttpStatus import org.springframework.http.MediaType import org.springframework.test.web.servlet.MockMvc - -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get - +import org.testcontainers.shaded.com.fasterxml.jackson.databind.ObjectMapper +import spock.lang.Shared import spock.lang.Specification -/** - * Specification for Query Controller. - */ @WebMvcTest(QueryController) +@Import([SortMapper, QueryResponseFactory, AnchorDetailsMapperImpl]) class QueryControllerSpec extends Specification { + @SpringBean + NetworkDataService mockNetworkDataService = Mock() + @Autowired MockMvc mvc - @Value('${rest.api.base-path}') - def basePath - def myDataspace = 'my-dataspace' + @Shared def myAnchor = 'my-anchor' + @Shared def mySchemaset = 'my-schemaset' + @Shared + def objectMapper = new ObjectMapper() - def 'Get anchors by name is not implemented.'(){ - given: 'an endpoint' - def getAnchorsByNameEndpoint = "${basePath}/v1/dataspaces/{dataspace-name}/anchors/{anchor-name}/history" + @Shared + def observedDescSortOrder = new Sort.Order(Sort.Direction.DESC, 'observed_timestamp') + @Shared + def anchorAscSortOrder = new Sort.Order(Sort.Direction.ASC, 'anchor') - when: 'get anchors by name endpoint is called' - def response = mvc.perform( get(getAnchorsByNameEndpoint, myDataspace, myAnchor) - .contentType(MediaType.APPLICATION_JSON)) - .andReturn().response + def 'Get #endpointName: default values if missing'() { - then: 'received unsupported operation response' - response.getStatus() == HttpStatus.NOT_IMPLEMENTED.value() + def controllerDataBuilder = new QueryControllerDataBuilder(endpointName, + [dataspace: myDataspace] << urlSpecifParams) + given: 'network data to be returned' + def networkData = createNetworkData() + when: 'endpoint is called without pageNumber, pageLimit, sort and pointInTime' + def requestBuilder = controllerDataBuilder. + createMockHttpRequestBuilder(); + def response = mvc.perform(requestBuilder).andReturn().response + then: 'pageNumber, pageSize and sort has default values' + interaction { + def expectedPageable = PageRequest.of(0, 1000, + Sort.by(Sort.Order.desc('observed_timestamp'))) + 1 * mockNetworkDataService.searchNetworkData(_ as SearchCriteria) >> { + SearchCriteria searchCriteria -> + assert searchCriteria.getPageable() == expectedPageable + assert searchCriteria.getObservedAfter() == null + assert searchCriteria.getCreatedBefore().isAfter(OffsetDateTime.now().minusMinutes(2)) + return new SliceImpl([networkData], searchCriteria.getPageable(), false) + } + } + and: 'response is ok' + response.getStatus() == HttpStatus.OK.value() + def anchorHistory = objectMapper.readValue(response.getContentAsString(), AnchorHistory) + and: 'content has expected values' + anchorHistory.getPreviousRecordsLink() == null + anchorHistory.getNextRecordsLink() == null + anchorHistory.getRecords() == List.of(toAnchorDetails(networkData)) + where: + endpointName | urlSpecifParams + 'anchor by name' | [anchor: myAnchor] + 'anchors by schemaset' | [schemaSet: mySchemaset] + } + def 'Get #endpointName: query data #scenario'() { + def inputParameters = [ + dataspace : myDataspace, + pointInTime : '2021-07-24T01:00:01.000-0400', + pageNumber : 2, pageLimit: 10, + sortAsString: 'observed_timestamp:desc'] + inputParameters << urlSpecifParams + inputParameters << parameters + def controllerDataBuilder = new QueryControllerDataBuilder(endpointName, inputParameters) + given: + def searchCriteria = controllerDataBuilder.createSearchCriteriaBuilder().build() + def networkData = createNetworkData() + mockNetworkDataService.searchNetworkData(searchCriteria) >> new SliceImpl( + List.of(networkData), searchCriteria.getPageable(), true) + when: 'endpoint is called with all parameters' + def requestBuilder = controllerDataBuilder.createMockHttpRequestBuilder() + def response = mvc.perform(requestBuilder + .contentType(MediaType.APPLICATION_JSON)).andReturn().response + def responseBody = objectMapper.readValue(response.getContentAsString(), AnchorHistory) + then: 'status is ok' + response.getStatus() == HttpStatus.OK.value() + and: 'next and previous record links have expected value' + controllerDataBuilder.isExpectedNextRecordsLink(responseBody.getNextRecordsLink()) + controllerDataBuilder.isExpectedPreviousRecordsLink(responseBody.getPreviousRecordsLink()) + and: 'has expected network data records' + responseBody.getRecords().size() == 1 + responseBody.getRecords() == [toAnchorDetails(networkData)] + where: + scenario | endpointName | urlSpecifParams | parameters + 'without observedTimestampAfter and with payloadFilter' | 'anchor by name' | [anchor: myAnchor] | [observedTimestampAfter: null, payloadFilter: null] + 'with observedTimestampAfter and without payloadFilter' | 'anchor by name' | [anchor: myAnchor] | [observedTimestampAfter: '2021-07-24T03:00:01.000-0400', payloadFilter: null] + 'without observedTimestampAfter and with payloadFilter' | 'anchor by name' | [anchor: myAnchor] | [observedTimestampAfter: null, payloadFilter: '{"message" : "hello+world"}'] + 'with observedTimestampAfter and with payloadFilter' | 'anchor by name' | [anchor: myAnchor] | [observedTimestampAfter: '2021-07-24T03:00:01.000+0400', payloadFilter: '{"message" : "hello world"}'] + 'without observedTimestampAfter and without payloadFilter' | 'anchors by schemaset' | [schemaSet: mySchemaset] | [observedTimestampAfter: null, payloadFilter: null] + 'with observedTimestampAfter and without payloadFilter' | 'anchors by schemaset' | [schemaSet: mySchemaset] | [observedTimestampAfter: '2021-07-24T03:00:01.000-0400', payloadFilter: null] + 'without observedTimestampAfter and with payloadFilter' | 'anchors by schemaset' | [schemaSet: mySchemaset] | [observedTimestampAfter: null, payloadFilter: '{"message" : "hello world"}'] + 'with observedTimestampAfter and with payloadFilter' | 'anchors by schemaset' | [schemaSet: mySchemaset] | [observedTimestampAfter: '2021-07-24T03:00:01.000+0400', payloadFilter: '{"message" : "hello world"}'] } - def 'Get anchors by dataspace name is not implemented.'(){ - given: 'an endpoint' - def getAnchorsByDataspaceEndpoint = "${basePath}/v1/dataspaces/{dataspace-name}/anchors/history" + def 'Get #endpointName: Sort by #sortAsString'() { + given: 'sort parameters' + def parameters = [dataspace: myDataspace, sortAsString: sortAsString] << uriSpecificParams + when: 'endpoint is called' + def controllerDataBuilder = new QueryControllerDataBuilder(endpointName, parameters) + def response = mvc.perform(controllerDataBuilder.createMockHttpRequestBuilder()) + .andReturn().response + then: 'network data service is called with expected sort' + 1 * mockNetworkDataService.searchNetworkData(_ as SearchCriteria) >> { + SearchCriteria searchCriteria -> + assert searchCriteria.getPageable().getSort() == expectedSort + return new SliceImpl([], searchCriteria.getPageable(), true) + } + and: 'response is ok' + response.getStatus() == HttpStatus.OK.value() + def anchorHistory = objectMapper.readValue(response.getContentAsString(), AnchorHistory) + and: 'content has expected values' + controllerDataBuilder.isExpectedNextRecordsLink(anchorHistory.getNextRecordsLink()) + anchorHistory.getPreviousRecordsLink() == null + where: + endpointName | uriSpecificParams | sortAsString || expectedSort + 'anchor by name' | [anchor: myAnchor] | 'observed_timestamp:desc' || Sort.by(observedDescSortOrder) + 'anchor by name' | [anchor: myAnchor] | 'anchor:asc,observed_timestamp:desc' || Sort.by(anchorAscSortOrder, observedDescSortOrder) + 'anchors by schemaset' | [schemaSet: mySchemaset] | 'observed_timestamp:desc' || Sort.by(observedDescSortOrder) + 'anchors by schemaset' | [schemaSet: mySchemaset] | 'anchor:asc,observed_timestamp:desc' || Sort.by(anchorAscSortOrder, observedDescSortOrder) + } - when: 'get anchors by dataspace name endpoint is called' - def response = mvc.perform( get(getAnchorsByDataspaceEndpoint, myDataspace).queryParam('schema-set-name', mySchemaset) - .contentType(MediaType.APPLICATION_JSON)) - .andReturn().response + def 'Get #endpointName Error handling: invalid date format in #queryParamName '() { + given: 'sort parameters' + def parameters = [dataspace: myDataspace] << uriSpecificParams + parameters[queryParamName] = 'invalid-date-string' + when: 'endpoint is called' + QueryControllerDataBuilder dataBuilder = new QueryControllerDataBuilder(endpointName, parameters) + def response = mvc.perform(dataBuilder.createMockHttpRequestBuilder()) + .andReturn().response + then: 'received bad request status' + response.getStatus() == HttpStatus.BAD_REQUEST.value() + and: 'error details' + def errorMessage = objectMapper.readValue(response.getContentAsString(), ErrorMessage) + errorMessage.getStatus() == HttpStatus.BAD_REQUEST.value().toString() + errorMessage.getMessage().contains(queryParamName) + errorMessage.getMessage().contains("yyyy-MM-dd'T'HH:mm:ss.SSSZ") + where: + endpointName | uriSpecificParams | queryParamName + 'anchor by name' | [anchor: myAnchor] | 'pointInTime' + 'anchor by name' | [anchor: myAnchor] | 'observedTimestampAfter' + 'anchors by schemaset' | [schemaSet: mySchemaset] | 'pointInTime' + 'anchors by schemaset' | [schemaSet: mySchemaset] | 'observedTimestampAfter' + } + + def 'Get #endpointName Error handling: invalid sort format #scenario'() { + given: 'sort parameters' + def parameters = [dataspace: myDataspace, sortAsString: sortAsString] << uriSpecificParams + when: 'endpoint is called' + def controllerDataBuilder = new QueryControllerDataBuilder(endpointName, parameters) + def response = mvc.perform(controllerDataBuilder.createMockHttpRequestBuilder()) + .andReturn().response + then: 'received bad request status' + response.getStatus() == HttpStatus.BAD_REQUEST.value() + and: 'error details' + def errorMessage = objectMapper.readValue(response.getContentAsString(), ErrorMessage) + errorMessage.getStatus() == HttpStatus.BAD_REQUEST.value().toString() + errorMessage.getMessage().contains("sort") + errorMessage.getMessage().contains("'$sortAsString'") + errorMessage.getMessage().contains(':,...,:') + where: + scenario | sortAsString | endpointName | uriSpecificParams + 'missing direction' | 'observed_timestamp' | 'anchor by name' | [anchor: myAnchor] + 'missing separator' | 'observed_timestampdesc' | 'anchor by name' | [anchor: myAnchor] + 'missing direction' | 'observed_timestamp' | 'anchors by schemaset' | [schemaSet: mySchemaset] + 'missing separator' | 'observed_timestampdesc' | 'anchors by schemaset' | [schemaSet: mySchemaset] + } - then: 'received unsupported operation response' - response.getStatus() == HttpStatus.NOT_IMPLEMENTED.value() + def 'Get #endpointName Error handling: invalid simple payload filter '() { + given: 'payload filter parameters' + def parameters = [dataspace: myDataspace, payloadFilter: 'invalid-json'] << uriSpecificParams + when: 'endpoint is called' + def controllerDataBuilder = new QueryControllerDataBuilder(endpointName, parameters) + def response = mvc.perform(controllerDataBuilder.createMockHttpRequestBuilder()) + .andReturn().response + then: 'received bad request status' + response.getStatus() == HttpStatus.BAD_REQUEST.value() + and: 'error details' + def errorMessage = objectMapper.readValue(response.getContentAsString(), ErrorMessage) + errorMessage.getStatus() == HttpStatus.BAD_REQUEST.value().toString() + errorMessage.getMessage().contains('simplePayloadFilter') + where: 'endpoints are provided' + endpointName | uriSpecificParams + 'anchor by name' | [anchor: myAnchor] + 'anchors by schemaset' | [schemaSet: mySchemaset] + } + NetworkData createNetworkData() { + return NetworkData.builder().dataspace(myDataspace) + .schemaSet(mySchemaset).anchor(myAnchor).payload('{"message" : "Hello World"}') + .observedTimestamp(OffsetDateTime.now()) + .createdTimestamp(OffsetDateTime.now()).build() } + AnchorDetails toAnchorDetails(NetworkData networkData) { + AnchorDetails anchorDetails = new AnchorDetails() + anchorDetails.setDataspace(networkData.getDataspace()) + anchorDetails.setAnchor(networkData.getAnchor()) + anchorDetails.setSchemaSet(networkData.getSchemaSet()) + anchorDetails.setObservedTimestamp(DateTimeUtility.toString(networkData.getObservedTimestamp())) + anchorDetails.setData(networkData.getPayload()) + return anchorDetails + } + + } diff --git a/src/test/groovy/org/onap/cps/temporal/domain/SearchCriteriaSpec.groovy b/src/test/groovy/org/onap/cps/temporal/domain/SearchCriteriaSpec.groovy index d7b6d1f..3d6a354 100644 --- a/src/test/groovy/org/onap/cps/temporal/domain/SearchCriteriaSpec.groovy +++ b/src/test/groovy/org/onap/cps/temporal/domain/SearchCriteriaSpec.groovy @@ -21,7 +21,6 @@ package org.onap.cps.temporal.domain import org.springframework.data.domain.Sort import spock.lang.Specification - import java.time.OffsetDateTime class SearchCriteriaSpec extends Specification { @@ -57,11 +56,13 @@ class SearchCriteriaSpec extends Specification { def 'Search Criteria with the provided values.'() { given: 'sort by parameter' - def sortBy = Sort.by(Sort.Direction.ASC, 'observed_timestamp') + def sortBy = Sort.by(Sort.Direction.DESC, 'observed_timestamp') and: 'data created one day ago' def lastDayAsCreatedBefore = OffsetDateTime.now().minusDays(1) and: 'observed timestamp' def nowAsObservedAfter = OffsetDateTime.now() + and: 'simple payload filter' + def simplePayloadFilter = '{"message":"hello world"}' when: 'search criteria is created' def searchCriteria = SearchCriteria.builder() @@ -69,6 +70,7 @@ class SearchCriteriaSpec extends Specification { .schemaSetName(myschemaSetName) .anchorName(myAnchorName) .pagination(0, 10) + .simplePayloadFilter(simplePayloadFilter) .sort(sortBy) .observedAfter(nowAsObservedAfter) .createdBefore(lastDayAsCreatedBefore) @@ -81,6 +83,7 @@ class SearchCriteriaSpec extends Specification { anchorName == myAnchorName observedAfter == nowAsObservedAfter createdBefore == lastDayAsCreatedBefore + it.simplePayloadFilter == simplePayloadFilter pageable.getPageNumber() == 0 pageable.getPageSize() == 10 pageable.getSort() == sortBy @@ -117,13 +120,35 @@ class SearchCriteriaSpec extends Specification { thrown(IllegalStateException) } - def 'Error Handling: sort must be not null.'() { + def 'Error Handling: sort based on #scenario .'() { when: 'search criteria is created without sorting information' SearchCriteria.builder() .dataspaceName(myDataspace) .anchorName(myAnchorName) .pagination(0, 1) - .sort(null) + .sort(sort) + .build() + then: 'exception is thrown' + def illegalArgumentException = thrown(IllegalArgumentException) + def message = illegalArgumentException.getMessage(); + assert message.contains("sort") + assert message.contains(expectedExceptionMessage) + where: + scenario | sort | expectedExceptionMessage + 'null' | null | "null" + 'unsupported properties' | Sort.by(Sort.Direction.ASC, 'unsupported') | "Invalid sorting" + 'missing required sort' | Sort.by(Sort.Direction.ASC, 'anchor') | 'Missing mandatory sort' + } + + def 'Error Handling: Invalid simple payload filter.'() { + given: 'invalid simple payload filter' + def inavlidSimplePayloadFilter = 'invalid-json' + when: 'search criteria is created without invalid simple payload filter' + SearchCriteria.builder() + .dataspaceName(myDataspace) + .anchorName(myAnchorName) + .pagination(0, 1) + .simplePayloadFilter(inavlidSimplePayloadFilter) .build() then: 'exception is thrown' thrown(IllegalArgumentException) diff --git a/src/test/groovy/org/onap/cps/temporal/repository/NetworkDataRepositoryImplSpec.groovy b/src/test/groovy/org/onap/cps/temporal/repository/NetworkDataRepositoryImplSpec.groovy index a5cc721..d33df75 100644 --- a/src/test/groovy/org/onap/cps/temporal/repository/NetworkDataRepositoryImplSpec.groovy +++ b/src/test/groovy/org/onap/cps/temporal/repository/NetworkDataRepositoryImplSpec.groovy @@ -165,8 +165,7 @@ class NetworkDataRepositoryImplSpec extends Specification { } where: scenario | sortOrder || expectedObservedTimestamp | expectedAnchorName - 'observed timestamp asc' | Sort.by(observedAscSortOrder) || '2021-07-22 00:00:01.000' | 'ANCHOR-01' - 'observed timestamp asc' | Sort.by(observedDescSortOrder) || '2021-07-24 00:00:01.000' | 'ANCHOR-02' + 'observed timestamp desc' | Sort.by(observedDescSortOrder) || '2021-07-24 00:00:01.000' | 'ANCHOR-02' 'anchor asc, ' + 'observed timestamp desc' | Sort.by(anchorAscSortOrder, observedDescSortOrder) || '2021-07-23 00:00:01.000' | 'ANCHOR-01' diff --git a/src/test/groovy/org/onap/cps/temporal/service/NetworkDataServiceImplSpec.groovy b/src/test/groovy/org/onap/cps/temporal/service/NetworkDataServiceImplSpec.groovy index c55c3c7..2e04ca8 100644 --- a/src/test/groovy/org/onap/cps/temporal/service/NetworkDataServiceImplSpec.groovy +++ b/src/test/groovy/org/onap/cps/temporal/service/NetworkDataServiceImplSpec.groovy @@ -22,7 +22,14 @@ package org.onap.cps.temporal.service import org.onap.cps.temporal.domain.NetworkDataId import org.onap.cps.temporal.domain.SearchCriteria +import org.spockframework.spring.SpringBean +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.test.context.SpringBootTest import org.springframework.data.domain.PageImpl +import org.springframework.test.context.ContextConfiguration + +import javax.validation.ValidationException import java.time.OffsetDateTime import org.onap.cps.temporal.domain.NetworkData import org.onap.cps.temporal.repository.NetworkDataRepository @@ -31,11 +38,18 @@ import spock.lang.Specification /** * Test specification for network data service. */ +@SpringBootTest +@ContextConfiguration(classes = NetworkDataServiceImpl) class NetworkDataServiceImplSpec extends Specification { - def mockNetworkDataRepository = Mock(NetworkDataRepository) + @SpringBean + NetworkDataRepository mockNetworkDataRepository = Mock() + + @Autowired + NetworkDataService objectUnderTest - def objectUnderTest = new NetworkDataServiceImpl(mockNetworkDataRepository) + @Value('${app.query.response.max-page-size}') + int maxPageSize def networkData = new NetworkData() @@ -88,4 +102,19 @@ class NetworkDataServiceImplSpec extends Specification { } + def 'Query network data with more than max page-size'() { + given: 'search criteria with more than max page size' + def searchCriteria = SearchCriteria.builder() + .dataspaceName('my-dataspaceName') + .schemaSetName('my-schemaset') + .pagination(0, maxPageSize + 1) + .build() + when: 'search is executed' + objectUnderTest.searchNetworkData(searchCriteria) + + then: 'throws error' + thrown(ValidationException) + + } + } diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index b2b8f00..fce4a17 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -61,3 +61,6 @@ app: listener: data-updated: topic: cps.cfg-state-events + query: + response: + max-page-size: 20 \ No newline at end of file -- cgit 1.2.3-korg