Merge branch 'develop' of https://github.com/mifosio/portfolio into develop
diff --git a/api/src/main/java/io/mifos/portfolio/api/v1/client/PortfolioManager.java b/api/src/main/java/io/mifos/portfolio/api/v1/client/PortfolioManager.java
index cd02791..b2868e5 100644
--- a/api/src/main/java/io/mifos/portfolio/api/v1/client/PortfolioManager.java
+++ b/api/src/main/java/io/mifos/portfolio/api/v1/client/PortfolioManager.java
@@ -18,6 +18,8 @@
 import io.mifos.core.api.annotation.ThrowsException;
 import io.mifos.core.api.util.CustomFeignClientsConfiguration;
 import io.mifos.portfolio.api.v1.domain.*;
+import io.mifos.portfolio.api.v1.validation.ValidSortColumn;
+import io.mifos.portfolio.api.v1.validation.ValidSortDirection;
 import org.springframework.cloud.netflix.feign.FeignClient;
 import org.springframework.http.HttpStatus;
 import org.springframework.http.MediaType;
@@ -58,7 +60,12 @@
       produces = MediaType.ALL_VALUE,
       consumes = MediaType.APPLICATION_JSON_VALUE
   )
-  List<Product> getAllProducts(@RequestParam(value = "includeDisabled", required = false) final Boolean includeDisabled);
+  ProductPage getProducts(@RequestParam(value = "includeDisabled", required = false) final Boolean includeDisabled,
+                          @RequestParam(value = "term", required = false) final String term,
+                          @RequestParam(value = "pageIndex") final Integer pageIndex,
+                          @RequestParam(value = "size") final Integer size,
+                          @RequestParam(value = "sortColumn", required = false) @ValidSortColumn(value = {"lastModifiedOn", "identifier", "name"}) final String sortColumn,
+                          @RequestParam(value = "sortDirection", required = false) @ValidSortDirection final String sortDirection);
 
   @RequestMapping(
           value = "/products",
diff --git a/api/src/main/java/io/mifos/portfolio/api/v1/domain/ProductPage.java b/api/src/main/java/io/mifos/portfolio/api/v1/domain/ProductPage.java
new file mode 100644
index 0000000..32e8962
--- /dev/null
+++ b/api/src/main/java/io/mifos/portfolio/api/v1/domain/ProductPage.java
@@ -0,0 +1,83 @@
+/*
+ * Copyright 2017 The Mifos Initiative.
+ *
+ * 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.
+ */
+package io.mifos.portfolio.api.v1.domain;
+
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * @author Myrle Krantz
+ */
+@SuppressWarnings("unused")
+public class ProductPage {
+  private List<Product> elements;
+  private Integer totalPages;
+  private Long totalElements;
+
+  public ProductPage(List<Product> elements, Integer totalPages, Long totalElements) {
+    this.elements = elements;
+    this.totalPages = totalPages;
+    this.totalElements = totalElements;
+  }
+
+  public List<Product> getElements() {
+    return elements;
+  }
+
+  public void setElements(List<Product> elements) {
+    this.elements = elements;
+  }
+
+  public Integer getTotalPages() {
+    return totalPages;
+  }
+
+  public void setTotalPages(Integer totalPages) {
+    this.totalPages = totalPages;
+  }
+
+  public Long getTotalElements() {
+    return totalElements;
+  }
+
+  public void setTotalElements(Long totalElements) {
+    this.totalElements = totalElements;
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (this == o) return true;
+    if (o == null || getClass() != o.getClass()) return false;
+    ProductPage casePage = (ProductPage) o;
+    return Objects.equals(elements, casePage.elements) &&
+            Objects.equals(totalPages, casePage.totalPages) &&
+            Objects.equals(totalElements, casePage.totalElements);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(elements, totalPages, totalElements);
+  }
+
+  @Override
+  public String toString() {
+    return "CasePage{" +
+            "elements=" + elements +
+            ", totalPages=" + totalPages +
+            ", totalElements=" + totalElements +
+            '}';
+  }
+}
diff --git a/api/src/main/java/io/mifos/portfolio/api/v1/validation/CheckValidSortColumn.java b/api/src/main/java/io/mifos/portfolio/api/v1/validation/CheckValidSortColumn.java
new file mode 100644
index 0000000..97c1578
--- /dev/null
+++ b/api/src/main/java/io/mifos/portfolio/api/v1/validation/CheckValidSortColumn.java
@@ -0,0 +1,29 @@
+package io.mifos.portfolio.api.v1.validation;
+
+import javax.validation.ConstraintValidator;
+import javax.validation.ConstraintValidatorContext;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * @author Myrle Krantz
+ */
+@SuppressWarnings("WeakerAccess")
+public class CheckValidSortColumn implements ConstraintValidator<ValidSortColumn, String> {
+  private Set<String> allowableColumns;
+
+  @Override
+  public void initialize(final ValidSortColumn constraintAnnotation) {
+    allowableColumns = new HashSet<>(Arrays.asList(constraintAnnotation.value()));
+  }
+
+  @Override
+  public boolean isValid(final String value, final ConstraintValidatorContext context) {
+    return validate(value, allowableColumns);
+  }
+
+  public static boolean validate(String value, Set<String> allowableColumns) {
+    return value == null || allowableColumns.contains(value);
+  }
+}
\ No newline at end of file
diff --git a/api/src/main/java/io/mifos/portfolio/api/v1/validation/CheckValidSortDirection.java b/api/src/main/java/io/mifos/portfolio/api/v1/validation/CheckValidSortDirection.java
new file mode 100644
index 0000000..ad09a58
--- /dev/null
+++ b/api/src/main/java/io/mifos/portfolio/api/v1/validation/CheckValidSortDirection.java
@@ -0,0 +1,24 @@
+package io.mifos.portfolio.api.v1.validation;
+
+import javax.validation.ConstraintValidator;
+import javax.validation.ConstraintValidatorContext;
+
+/**
+ * @author Myrle Krantz
+ */
+@SuppressWarnings("WeakerAccess")
+public class CheckValidSortDirection implements ConstraintValidator<ValidSortDirection, String> {
+  @Override
+  public void initialize(ValidSortDirection constraintAnnotation) {
+
+  }
+
+  @Override
+  public boolean isValid(String value, ConstraintValidatorContext context) {
+    return validate(value);
+  }
+
+  public static boolean validate(String value) {
+    return (value == null) || (value.equals("ASC") || (value.equals("DESC")));
+  }
+}
diff --git a/api/src/main/java/io/mifos/portfolio/api/v1/validation/ValidAccountAssignments.java b/api/src/main/java/io/mifos/portfolio/api/v1/validation/ValidAccountAssignments.java
index d55672a..956b3d0 100644
--- a/api/src/main/java/io/mifos/portfolio/api/v1/validation/ValidAccountAssignments.java
+++ b/api/src/main/java/io/mifos/portfolio/api/v1/validation/ValidAccountAssignments.java
@@ -35,6 +35,4 @@
   Class<?>[] groups() default {};
 
   Class<? extends Payload>[] payload() default {};
-
-  int maxLength() default 32;
 }
diff --git a/api/src/main/java/io/mifos/portfolio/api/v1/validation/ValidPaymentCycleUnit.java b/api/src/main/java/io/mifos/portfolio/api/v1/validation/ValidPaymentCycleUnit.java
index 816af95..853ab24 100644
--- a/api/src/main/java/io/mifos/portfolio/api/v1/validation/ValidPaymentCycleUnit.java
+++ b/api/src/main/java/io/mifos/portfolio/api/v1/validation/ValidPaymentCycleUnit.java
@@ -35,6 +35,4 @@
   Class<?>[] groups() default {};
 
   Class<? extends Payload>[] payload() default {};
-
-  int maxLength() default 32;
 }
diff --git a/api/src/main/java/io/mifos/portfolio/api/v1/validation/ValidSortColumn.java b/api/src/main/java/io/mifos/portfolio/api/v1/validation/ValidSortColumn.java
new file mode 100644
index 0000000..cf367a0
--- /dev/null
+++ b/api/src/main/java/io/mifos/portfolio/api/v1/validation/ValidSortColumn.java
@@ -0,0 +1,25 @@
+package io.mifos.portfolio.api.v1.validation;
+
+import javax.validation.Constraint;
+import javax.validation.Payload;
+import java.lang.annotation.*;
+
+/**
+ * @author Myrle Krantz
+ */
+@SuppressWarnings("unused")
+@Target({ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER})
+@Retention(RetentionPolicy.RUNTIME)
+@Documented
+@Constraint(
+        validatedBy = {CheckValidSortColumn.class}
+)
+public @interface ValidSortColumn {
+  String message() default "Use a sort column available in the data model.";
+
+  Class<?>[] groups() default {};
+
+  Class<? extends Payload>[] payload() default {};
+
+  String[] value();
+}
diff --git a/api/src/main/java/io/mifos/portfolio/api/v1/validation/ValidSortDirection.java b/api/src/main/java/io/mifos/portfolio/api/v1/validation/ValidSortDirection.java
new file mode 100644
index 0000000..b1f6865
--- /dev/null
+++ b/api/src/main/java/io/mifos/portfolio/api/v1/validation/ValidSortDirection.java
@@ -0,0 +1,23 @@
+package io.mifos.portfolio.api.v1.validation;
+
+import javax.validation.Constraint;
+import javax.validation.Payload;
+import java.lang.annotation.*;
+
+/**
+ * @author Myrle Krantz
+ */
+@SuppressWarnings("unused")
+@Target({ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER})
+@Retention(RetentionPolicy.RUNTIME)
+@Documented
+@Constraint(
+        validatedBy = {CheckValidSortDirection.class}
+)
+public @interface ValidSortDirection {
+  String message() default "Only ASC, and DESC are valid sort directions.";
+
+  Class<?>[] groups() default {};
+
+  Class<? extends Payload>[] payload() default {};
+}
diff --git a/component-test/src/main/java/io/mifos/portfolio/TestProducts.java b/component-test/src/main/java/io/mifos/portfolio/TestProducts.java
index 1db9f00..41160a4 100644
--- a/component-test/src/main/java/io/mifos/portfolio/TestProducts.java
+++ b/component-test/src/main/java/io/mifos/portfolio/TestProducts.java
@@ -52,10 +52,98 @@
 
     Assert.assertFalse(portfolioManager.getProductEnabled(product.getIdentifier()));
 
+    {
+      final ProductPage productsPage = portfolioManager.getProducts(true, null, 0, 100, null, null);
+      Assert.assertTrue(productsPage.getElements().contains(productAsSaved));
+    }
+
+    {
+      final ProductPage productsPage = portfolioManager.getProducts(true, product.getIdentifier().substring(2, 5), 0, 100, null, null);
+      Assert.assertTrue(productsPage.getElements().contains(productAsSaved));
+    }
+
+    {
+      final ProductPage productsPage = portfolioManager.getProducts(false, null, 0, 100, null, null);
+      Assert.assertFalse(productsPage.getElements().contains(productAsSaved));
+    }
+
+    {
+      final ProductPage productsPage = portfolioManager.getProducts(false, product.getIdentifier().substring(2, 5), 0, 100, null, null);
+      Assert.assertFalse(productsPage.getElements().contains(productAsSaved));
+    }
+
     portfolioManager.enableProduct(product.getIdentifier(), true);
     Assert.assertTrue(this.eventRecorder.wait(EventConstants.PUT_PRODUCT_ENABLE, product.getIdentifier()));
 
     Assert.assertTrue(portfolioManager.getProductEnabled(product.getIdentifier()));
+
+    {
+      final ProductPage productsPage = portfolioManager.getProducts(false, null, 0, 100, null, null);
+      Assert.assertTrue(productsPage.getElements().contains(productAsSaved));
+    }
+
+    {
+      final ProductPage productsPage = portfolioManager.getProducts(false, product.getIdentifier().substring(2, 5), 0, 100, null, null);
+      Assert.assertTrue(productsPage.getElements().contains(productAsSaved));
+    }
+  }
+
+  @Test
+  public void shouldCorrectlyOrderProducts() throws InterruptedException {
+
+    final Product productA = createAdjustedProduct(x -> {
+      x.setIdentifier("aaaaaaaa");
+      x.setName("ZZZZZZZ");
+    });
+    final Product productZ = createAdjustedProduct(x -> {
+      x.setIdentifier("zzzzzzzz");
+      x.setName("AAAAAAA");
+    });
+
+    final Product productASaved = portfolioManager.getProduct(productA.getIdentifier());
+    final Product productZSaved = portfolioManager.getProduct(productZ.getIdentifier());
+
+    {
+      final ProductPage productsPage = portfolioManager.getProducts(true, null, 0, 100, null, null);
+      Assert.assertEquals(productZSaved, productsPage.getElements().get(0)); //Modified by ordering.
+    }
+
+    {
+      final ProductPage productsPage = portfolioManager.getProducts(true, null, 0, 100, "identifier", "ASC");
+      Assert.assertEquals(productASaved, productsPage.getElements().get(0)); //Alphabetic ordering by identifier
+    }
+
+    {
+      final ProductPage productsPage = portfolioManager.getProducts(true, null, 0, 100, "name", "DESC");
+      Assert.assertEquals(productASaved, productsPage.getElements().get(0)); //Alphabetic ordering by name. Descending.
+    }
+  }
+
+  @Test
+  public void badArgumentsToSortOrderAndDirectionShouldThrow() throws InterruptedException {
+    try {
+      portfolioManager.getProducts(true, null, 0, 100, null, "asc");
+      Assert.fail("Should've thrown");
+    }
+    catch (final IllegalArgumentException ignored) { }
+
+    try {
+      portfolioManager.getProducts(true, null, 0, 100, null, "ACS");
+      Assert.fail("Should've thrown");
+    }
+    catch (final IllegalArgumentException ignored) { }
+
+    try {
+      portfolioManager.getProducts(true, null, 0, 100, "non-existent-column", null);
+      Assert.fail("Should've thrown");
+    }
+    catch (final IllegalArgumentException ignored) { }
+
+    try {
+      portfolioManager.getProducts(true, null, 0, 100, "", null);
+      Assert.fail("Should've thrown");
+    }
+    catch (final IllegalArgumentException ignored) { }
   }
 
   @Test
diff --git a/service/src/main/java/io/mifos/portfolio/service/internal/repository/ProductRepository.java b/service/src/main/java/io/mifos/portfolio/service/internal/repository/ProductRepository.java
index 57c4614..06fa008 100644
--- a/service/src/main/java/io/mifos/portfolio/service/internal/repository/ProductRepository.java
+++ b/service/src/main/java/io/mifos/portfolio/service/internal/repository/ProductRepository.java
@@ -15,6 +15,8 @@
  */
 package io.mifos.portfolio.service.internal.repository;
 
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
 import org.springframework.data.jpa.repository.JpaRepository;
 import org.springframework.stereotype.Repository;
 
@@ -26,4 +28,7 @@
 @Repository
 public interface ProductRepository extends JpaRepository<ProductEntity, Long> {
   Optional<ProductEntity> findByIdentifier(String identifier);
+  Page<ProductEntity> findByEnabled(boolean enabled, Pageable pageable);
+  Page<ProductEntity> findByIdentifierContaining(final String term, final Pageable pageable);
+  Page<ProductEntity> findByEnabledAndIdentifierContaining(boolean enabled, final String term, final Pageable pageable);
 }
diff --git a/service/src/main/java/io/mifos/portfolio/service/internal/service/ProductService.java b/service/src/main/java/io/mifos/portfolio/service/internal/service/ProductService.java
index dd97c33..7d8b475 100644
--- a/service/src/main/java/io/mifos/portfolio/service/internal/service/ProductService.java
+++ b/service/src/main/java/io/mifos/portfolio/service/internal/service/ProductService.java
@@ -18,13 +18,20 @@
 import io.mifos.portfolio.api.v1.domain.AccountAssignment;
 import io.mifos.portfolio.api.v1.domain.ChargeDefinition;
 import io.mifos.portfolio.api.v1.domain.Product;
+import io.mifos.portfolio.api.v1.domain.ProductPage;
 import io.mifos.portfolio.service.internal.mapper.ProductMapper;
 import io.mifos.portfolio.service.internal.repository.ProductEntity;
 import io.mifos.portfolio.service.internal.repository.ProductRepository;
 import io.mifos.portfolio.service.internal.util.AccountingAdapter;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Sort;
 import org.springframework.stereotype.Service;
 
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
 import java.util.*;
 import java.util.stream.Collectors;
 
@@ -48,8 +55,48 @@
     this.accountingAdapter = accountingAdapter;
   }
 
-  public List<Product> findAllEntities() {
-    return ProductMapper.map(this.productRepository.findAll());
+  public ProductPage findEntities(final boolean includeDisabled,
+                                  final @Nullable String term,
+                                  final int pageIndex,
+                                  final int size,
+                                  final @Nullable String sortColumn,
+                                  final @Nullable String sortDirection) {
+    final Pageable pageRequest = new PageRequest(pageIndex,
+            size,
+            translateSortDirection(sortDirection),
+            translateSortColumn(sortColumn));
+
+    final Page<ProductEntity> ret;
+    if (includeDisabled) {
+      if (term == null)
+        ret = productRepository.findAll(pageRequest);
+      else
+        ret = productRepository.findByIdentifierContaining(term, pageRequest);
+    }
+    else {
+      if (term == null)
+        ret = productRepository.findByEnabled(true, pageRequest);
+      else
+        ret = productRepository.findByEnabledAndIdentifierContaining(true, term, pageRequest);
+    }
+
+    return new ProductPage(ProductMapper.map(ret.getContent()), ret.getTotalPages(), ret.getTotalElements());
+  }
+
+  private Sort.Direction translateSortDirection(@Nullable final String sortDirection) {
+    return sortDirection == null ? Sort.Direction.DESC :
+          Sort.Direction.valueOf(sortDirection);
+  }
+
+  private @Nonnull
+  String translateSortColumn(@Nullable final String sortColumn) {
+    if (sortColumn == null)
+      return "lastModifiedOn";
+
+    if (!sortColumn.equals("name") && !sortColumn.equals("identifier") && !sortColumn.equals("lastModifiedOn"))
+      throw new IllegalStateException("Illegal input for Sort Column should've been blocked in Rest Controller.");
+
+    return sortColumn;
   }
 
   public Optional<Product> findByIdentifier(final String identifier)
diff --git a/service/src/main/java/io/mifos/portfolio/service/rest/ProductRestController.java b/service/src/main/java/io/mifos/portfolio/service/rest/ProductRestController.java
index c8f5928..fe117f7 100644
--- a/service/src/main/java/io/mifos/portfolio/service/rest/ProductRestController.java
+++ b/service/src/main/java/io/mifos/portfolio/service/rest/ProductRestController.java
@@ -24,6 +24,10 @@
 import io.mifos.portfolio.api.v1.domain.AccountAssignment;
 import io.mifos.portfolio.api.v1.domain.Pattern;
 import io.mifos.portfolio.api.v1.domain.Product;
+import io.mifos.portfolio.api.v1.domain.ProductPage;
+import io.mifos.portfolio.api.v1.validation.CheckValidSortColumn;
+import io.mifos.portfolio.api.v1.validation.CheckValidSortDirection;
+import io.mifos.portfolio.api.v1.validation.ValidSortDirection;
 import io.mifos.portfolio.service.internal.command.ChangeEnablingOfProductCommand;
 import io.mifos.portfolio.service.internal.command.ChangeProductCommand;
 import io.mifos.portfolio.service.internal.command.CreateProductCommand;
@@ -36,8 +40,10 @@
 import org.springframework.http.ResponseEntity;
 import org.springframework.web.bind.annotation.*;
 
+import javax.annotation.Nullable;
 import javax.validation.Valid;
-import java.util.List;
+import java.util.Arrays;
+import java.util.HashSet;
 import java.util.Set;
 
 /**
@@ -47,6 +53,7 @@
 @RestController //
 @RequestMapping("/products") //
 public class ProductRestController {
+  private final static Set<String> VALID_SORT_COLUMNS = new HashSet<>(Arrays.asList("lastModifiedOn", "identifier", "name"));
 
   private final CommandGateway commandGateway;
   private final CaseService caseService;
@@ -62,12 +69,23 @@
     this.caseService = caseService;
     this.productService = productService;
     this.patternService = patternService;
+
   }
 
   @Permittable(value = AcceptedTokenType.TENANT, groupId = PermittableGroupIds.PRODUCT_MANAGEMENT)
   @RequestMapping(method = RequestMethod.GET) //
-  public @ResponseBody List<Product> getAllEntities() {
-    return this.productService.findAllEntities();
+  public @ResponseBody
+  ProductPage getProducts(@RequestParam(value = "includeDisabled", required = false) final Boolean includeDisabled,
+                          @RequestParam(value = "term", required = false) final @Nullable String term,
+                          @RequestParam(value = "pageIndex") final Integer pageIndex,
+                          @RequestParam(value = "size") final Integer size,
+                          @RequestParam(value = "sortColumn", required = false) final String sortColumn,
+                          @RequestParam(value = "sortDirection", required = false) final @Valid @ValidSortDirection String sortDirection) {
+    if (!CheckValidSortColumn.validate(sortColumn, VALID_SORT_COLUMNS))
+      throw ServiceException.badRequest("Invalid sort column ''{0}''.  Valid inputs are ''{1}''.", sortColumn, VALID_SORT_COLUMNS);
+    if (!CheckValidSortDirection.validate(sortDirection))
+      throw ServiceException.badRequest("Invalid sort direction ''{0}''.", sortDirection);
+    return this.productService.findEntities(includeDisabled, term, pageIndex, size, sortColumn, sortDirection);
   }
 
   @Permittable(value = AcceptedTokenType.TENANT, groupId = PermittableGroupIds.PRODUCT_MANAGEMENT)