Validate controller input and provide expected response 47/123247/9
authorRenu Kumari <renu.kumari@bell.ca>
Tue, 10 Aug 2021 20:43:04 +0000 (16:43 -0400)
committerRenu Kumari <renu.kumari@bell.ca>
Thu, 19 Aug 2021 11:13:57 +0000 (07:13 -0400)
- 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

19 files changed:
docs/api/swagger/openapi.yml
pom.xml
src/main/java/org/onap/cps/temporal/controller/event/model/CpsDataUpdatedEventMapper.java
src/main/java/org/onap/cps/temporal/controller/rest/QueryController.java
src/main/java/org/onap/cps/temporal/controller/rest/QueryExceptionHandler.java [new file with mode: 0644]
src/main/java/org/onap/cps/temporal/controller/rest/QueryResponseFactory.java [new file with mode: 0644]
src/main/java/org/onap/cps/temporal/controller/rest/model/AnchorDetailsMapper.java [new file with mode: 0644]
src/main/java/org/onap/cps/temporal/controller/rest/model/SortMapper.java [new file with mode: 0644]
src/main/java/org/onap/cps/temporal/controller/utils/DateTimeUtility.java [new file with mode: 0644]
src/main/java/org/onap/cps/temporal/domain/SearchCriteria.java
src/main/java/org/onap/cps/temporal/service/NetworkDataServiceImpl.java
src/main/resources/application.yml
src/test/groovy/org/onap/cps/temporal/controller/event/model/CpsDataUpdatedEventMapperSpec.groovy
src/test/groovy/org/onap/cps/temporal/controller/rest/QueryControllerDataBuilder.groovy [new file with mode: 0644]
src/test/groovy/org/onap/cps/temporal/controller/rest/QueryControllerSpec.groovy
src/test/groovy/org/onap/cps/temporal/domain/SearchCriteriaSpec.groovy
src/test/groovy/org/onap/cps/temporal/repository/NetworkDataRepositoryImplSpec.groovy
src/test/groovy/org/onap/cps/temporal/service/NetworkDataServiceImplSpec.groovy
src/test/resources/application.yml

index cde25a8..991d807 100644 (file)
@@ -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 (executable)
--- a/pom.xml
+++ b/pom.xml
             <groupId>org.springframework.boot</groupId>
             <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>
index 9ef25d5..d180509 100644 (file)
@@ -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);
     }
 
 }
index e7171a0..ab29e19 100644 (file)
 
 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 (file)
index 0000000..d620dbe
--- /dev/null
@@ -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 (file)
index 0000000..6ac4759
--- /dev/null
@@ -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 (file)
index 0000000..1c44c36
--- /dev/null
@@ -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 (file)
index 0000000..cd553eb
--- /dev/null
@@ -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 (file)
index 0000000..b36904e
--- /dev/null
@@ -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;
+    }
+}
index 8188d84..4cd6a20 100644 (file)
 
 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;
         }
index 7c2f999..3eba6fb 100644 (file)
 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);
     }
 
index c79351a..41eddf8 100755 (executable)
@@ -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:
index 132ff6d..a51c4fe 100644 (file)
@@ -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 (file)
index 0000000..dee1e06
--- /dev/null
@@ -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
+        }
+    }
+
+}
index 771a3fc..a18a134 100644 (file)
 
 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
+    }
+
+
 }
index d7b6d1f..3d6a354 100644 (file)
@@ -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)
index a5cc721..d33df75 100644 (file)
@@ -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'
 
index c55c3c7..2e04ca8 100644 (file)
@@ -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)
+
+    }
+
 }
index b2b8f00..fce4a17 100644 (file)
@@ -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