aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorRenu Kumari <renu.kumari@bell.ca>2021-08-10 16:43:04 -0400
committerRenu Kumari <renu.kumari@bell.ca>2021-08-19 07:13:57 -0400
commitea04c07ad990b5543766e95e234cae746bd1fbc1 (patch)
treec50008212dbf1471e859c0f9f681baa146a57c7c
parent25ed5a8a63e88edd0b9c8285b640839a6e93866a (diff)
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 <renu.kumari@bell.ca> Change-Id: I7936cd9e8e7dead3b5650b421bb12f10d14ffa9b
-rw-r--r--docs/api/swagger/openapi.yml16
-rwxr-xr-xpom.xml4
-rw-r--r--src/main/java/org/onap/cps/temporal/controller/event/model/CpsDataUpdatedEventMapper.java11
-rw-r--r--src/main/java/org/onap/cps/temporal/controller/rest/QueryController.java93
-rw-r--r--src/main/java/org/onap/cps/temporal/controller/rest/QueryExceptionHandler.java73
-rw-r--r--src/main/java/org/onap/cps/temporal/controller/rest/QueryResponseFactory.java170
-rw-r--r--src/main/java/org/onap/cps/temporal/controller/rest/model/AnchorDetailsMapper.java38
-rw-r--r--src/main/java/org/onap/cps/temporal/controller/rest/model/SortMapper.java80
-rw-r--r--src/main/java/org/onap/cps/temporal/controller/utils/DateTimeUtility.java40
-rw-r--r--src/main/java/org/onap/cps/temporal/domain/SearchCriteria.java56
-rw-r--r--src/main/java/org/onap/cps/temporal/service/NetworkDataServiceImpl.java10
-rwxr-xr-xsrc/main/resources/application.yml3
-rw-r--r--src/test/groovy/org/onap/cps/temporal/controller/event/model/CpsDataUpdatedEventMapperSpec.groovy2
-rw-r--r--src/test/groovy/org/onap/cps/temporal/controller/rest/QueryControllerDataBuilder.groovy159
-rw-r--r--src/test/groovy/org/onap/cps/temporal/controller/rest/QueryControllerSpec.groovy234
-rw-r--r--src/test/groovy/org/onap/cps/temporal/domain/SearchCriteriaSpec.groovy33
-rw-r--r--src/test/groovy/org/onap/cps/temporal/repository/NetworkDataRepositoryImplSpec.groovy3
-rw-r--r--src/test/groovy/org/onap/cps/temporal/service/NetworkDataServiceImplSpec.groovy33
-rw-r--r--src/test/resources/application.yml3
19 files changed, 998 insertions, 63 deletions
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: <br/> timestamp:desc<br/>timestamp:asc"
+ description: "Sort by timestamp in 'asc' or 'desc' order. Supported values: <br/>observed_timestamp:desc<br/>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
@@ -117,6 +117,10 @@
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
+ <groupId>org.springframework.boot</groupId>
+ <artifactId>spring-boot-starter-hateoas</artifactId>
+ </dependency>
+ <dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${mapstruct.version}</version>
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<AnchorHistory> 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<NetworkData> searchResult = networkDataService.searchNetworkData(searchCriteria);
+ final var anchorHistory = queryResponseFactory
+ .createAnchorDataByNameResponse(searchCriteria, searchResult);
+ return ResponseEntity.ok(anchorHistory);
}
@Override
public ResponseEntity<AnchorHistory> 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<NetworkData> 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<ErrorMessage> handleClientError(final ValidationException validationException) {
+ return buildErrorMessage(HttpStatus.BAD_REQUEST, validationException);
+ }
+
+ @ExceptionHandler({IllegalArgumentException.class})
+ public ResponseEntity<ErrorMessage> handleClientError(final IllegalArgumentException illegalArgumentException) {
+ return logAndBuildErrorMessage(HttpStatus.BAD_REQUEST, illegalArgumentException);
+ }
+
+ @ExceptionHandler
+ public ResponseEntity<ErrorMessage> handleInternalServerError(final Exception exception) {
+ return logAndBuildErrorMessage(HttpStatus.INTERNAL_SERVER_ERROR, exception);
+ }
+
+ private ResponseEntity<ErrorMessage> 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<ErrorMessage> 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<NetworkData> 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<NetworkData> 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<AnchorDetails> convertToAnchorDetails(final List<NetworkData> 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<Order> 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 '<fieldname>:<direction>,...,<fieldname>:<direction>'"
+ + " 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<Order> REQUIRED_SORT_ORDERS = List.of(Order.desc(OBSERVED_TIMESTAMP_FIELD_NAME));
+ private static final List<Order> 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<Order> 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<NetworkData> 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<String, String> queryParams) {
+ def values = queryParams.get(paramName)
+ assert (!CollectionUtils.isEmpty(values))
+ assert (expectedValue == URLDecoder.decode(values.get(0), Charset.defaultCharset()))
+ }
+
+ boolean validatePointInTime(MultiValueMap<String, String> 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<NetworkData>(
+ 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('<fieldname>:<direction>,...,<fieldname>:<direction>')
+ 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