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 --- .../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 + 10 files changed, 557 insertions(+), 17 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 (limited to 'src/main') 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: -- cgit 1.2.3-korg