This is an automated email from the ASF dual-hosted git repository.

diveshdut pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/ofbiz-framework.git

commit d50791ab95c71b0eef9ceba3fa13c5b1611c09ff
Author: diveshdut <[email protected]>
AuthorDate: Thu Aug 13 15:19:05 2026 +0530

    OFBIZ-13457 Add REST list query helper utilities
    
    Adds reusable REST helper support for derived list APIs: in-memory list 
pagination, map-row sorting through the existing MapComparator, relative 
request-path lookup for pagination links, and public filter validation entry 
points.
    
    Adds focused coverage for request-path handling, computed-list pagination, 
map-row sort validation, filter validation behavior, and Groovy service binding 
request lookup. These helpers let endpoint services share the framework paging, 
sorting, filtering, and link-generation contract instead of carrying local 
implementations.
---
 .../java/org/apache/ofbiz/base/util/UtilHttp.java  |  13 +++
 .../org/apache/ofbiz/base/util/UtilHttpTest.java   |  13 +++
 .../org/apache/ofbiz/ws/rs/util/RestApiUtil.java   | 110 ++++++++++++++++++++-
 .../ws/rs/util/RestApiUtilPaginationTest.java      | 107 +++++++++++++++++++-
 4 files changed, 238 insertions(+), 5 deletions(-)

diff --git 
a/framework/base/src/main/java/org/apache/ofbiz/base/util/UtilHttp.java 
b/framework/base/src/main/java/org/apache/ofbiz/base/util/UtilHttp.java
index 469eb29b75..e1958217cc 100644
--- a/framework/base/src/main/java/org/apache/ofbiz/base/util/UtilHttp.java
+++ b/framework/base/src/main/java/org/apache/ofbiz/base/util/UtilHttp.java
@@ -832,6 +832,19 @@ public final class UtilHttp {
         return requestUrl.toString();
     }
 
+    /**
+     * Returns the relative request URI plus query string, without scheme, 
host, or port.
+     *
+     * @param request the servlet request to read
+     * @return the relative request URI with query string, or {@code null} 
when the request is unavailable
+     */
+    public static String getRelativeRequestPath(HttpServletRequest request) {
+        if (request == null || UtilValidate.isEmpty(request.getRequestURI())) {
+            return null;
+        }
+        return request.getRequestURI() + 
(UtilValidate.isNotEmpty(request.getQueryString()) ? "?" + 
request.getQueryString() : "");
+    }
+
     /**
      * Resolve the method send with the request.
      * check first the parameter _method before return the request method
diff --git 
a/framework/base/src/test/java/org/apache/ofbiz/base/util/UtilHttpTest.java 
b/framework/base/src/test/java/org/apache/ofbiz/base/util/UtilHttpTest.java
index e9288150b5..5aafd4dd3d 100644
--- a/framework/base/src/test/java/org/apache/ofbiz/base/util/UtilHttpTest.java
+++ b/framework/base/src/test/java/org/apache/ofbiz/base/util/UtilHttpTest.java
@@ -155,6 +155,19 @@ public final class UtilHttpTest {
         assertNull(UtilHttp.makeParamValueFromComposite(req, "meetingDate"));
     }
 
+    @Test
+    public void getRelativeRequestPathIncludesQueryString() {
+        when(req.getRequestURI()).thenReturn("/rest/items");
+        when(req.getQueryString()).thenReturn("pageIndex=1&pageSize=1");
+
+        assertThat(UtilHttp.getRelativeRequestPath(req), 
equalTo("/rest/items?pageIndex=1&pageSize=1"));
+    }
+
+    @Test
+    public void getRelativeRequestPathReturnsNullWhenRequestMissing() {
+        assertNull(UtilHttp.getRelativeRequestPath(null));
+    }
+
     @Test
     public void ampmMakeParamValueFromComposite() {
         
when(req.getParameter("meetingDate_c_compositeType")).thenReturn("Timestamp");
diff --git 
a/framework/rest-api/src/main/java/org/apache/ofbiz/ws/rs/util/RestApiUtil.java 
b/framework/rest-api/src/main/java/org/apache/ofbiz/ws/rs/util/RestApiUtil.java
index 53962a8dff..cb8c6600c6 100644
--- 
a/framework/rest-api/src/main/java/org/apache/ofbiz/ws/rs/util/RestApiUtil.java
+++ 
b/framework/rest-api/src/main/java/org/apache/ofbiz/ws/rs/util/RestApiUtil.java
@@ -24,6 +24,7 @@ import java.net.URLEncoder;
 import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
 import java.util.Collections;
+import java.util.Comparator;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.LinkedHashMap;
@@ -34,14 +35,18 @@ import java.util.Map;
 import java.util.Set;
 
 import org.apache.ofbiz.base.util.UtilGenerics;
+import org.apache.ofbiz.base.util.UtilHttp;
 import org.apache.ofbiz.base.util.UtilMisc;
 import org.apache.ofbiz.base.util.UtilProperties;
 import org.apache.ofbiz.base.util.UtilValidate;
+import org.apache.ofbiz.base.util.collections.MapComparator;
 import org.apache.ofbiz.service.ModelService;
 import org.apache.ofbiz.ws.rs.core.ResponseStatus;
 import org.apache.ofbiz.ws.rs.response.Error;
 import org.apache.ofbiz.ws.rs.response.Success;
 
+import groovy.lang.Binding;
+import jakarta.servlet.http.HttpServletRequest;
 import jakarta.ws.rs.core.MediaType;
 import jakarta.ws.rs.core.MultivaluedMap;
 import jakarta.ws.rs.core.Response;
@@ -433,9 +438,107 @@ public final class RestApiUtil {
         return orderBy;
     }
 
+    /**
+     * Returns a page slice from an already computed list.
+     * <p>
+     * Purpose: lets REST services apply framework-style pagination after they
+     * build derived rows that cannot be paged directly through EntityQuery.
+     *
+     * @param rows the complete in-memory result list
+     * @param pageIndex the zero-based page index
+     * @param pageSize the requested page size
+     * @param <T> the row type
+     * @return a new list containing the requested page, or an empty list when 
the page is beyond the result size
+     */
+    public static <T> List<T> pageList(List<T> rows, int pageIndex, int 
pageSize) {
+        if (UtilValidate.isEmpty(rows)) {
+            return Collections.emptyList();
+        }
+        long fromIndex = (long) pageIndex * pageSize;
+        if (fromIndex >= rows.size()) {
+            return Collections.emptyList();
+        }
+        int safeFromIndex = (int) fromIndex;
+        int toIndex = Math.min(safeFromIndex + pageSize, rows.size());
+        return new ArrayList<>(rows.subList(safeFromIndex, toIndex));
+    }
+
+    /**
+     * Sorts already computed map rows using REST sort-expression syntax.
+     * <p>
+     * Purpose: lets REST services expose the same {@code sort} contract for
+     * derived map rows that EntityQuery-backed endpoints expose for entity 
rows.
+     * The framework MapComparator performs the row comparison while this 
method
+     * handles REST sort validation and stable fallback ordering.
+     *
+     * @param rows the complete in-memory result rows
+     * @param sortExpression comma-separated REST sort tokens, optionally 
prefixed with {@code -}
+     * @param allowedFields endpoint-supported map fields, or {@code 
null}/empty to apply syntax-only validation
+     * @param fallbackComparator stable comparator used when no sort was 
requested or requested values tie
+     * @return a sorted list, or an empty list when no rows exist
+     * @throws IllegalArgumentException when a sort token is malformed or 
unsupported
+     */
+    public static List<Map<String, Object>> sortMapRows(List<Map<String, 
Object>> rows, String sortExpression,
+            Set<String> allowedFields, Comparator<Map<String, Object>> 
fallbackComparator) {
+        List<String> sortTokens = validateSortFields(sortExpression, 
allowedFields);
+        if (UtilValidate.isEmpty(rows)) {
+            return Collections.emptyList();
+        }
+        Comparator<Map<String, Object>> rowComparator = fallbackComparator;
+        if (UtilValidate.isNotEmpty(sortTokens)) {
+            MapComparator sortComparator = new MapComparator(sortTokens);
+            rowComparator = (left, right) -> {
+                int comparison = 
sortComparator.compare(UtilGenerics.cast(left), UtilGenerics.cast(right));
+                return comparison != 0 ? comparison : 
fallbackComparator.compare(left, right);
+            };
+        }
+        return new ArrayList<>(rows.stream()
+                .sorted(rowComparator)
+                .toList());
+    }
+
+    /**
+     * Extracts the relative request URI plus query string for pagination link 
generation.
+     * <p>
+     * Purpose: REST pagination links operate on the request path stored in
+     * {@link RestListResponseBuilder}, not on an absolute URL. This keeps API
+     * responses consistent with existing relative link output while avoiding
+     * repeated URI/query-string assembly in individual services. Use
+     * {@code UtilHttp.getFullRequestUrl(request)} when an absolute URL is 
needed.
+     *
+     * @param request the originating servlet request
+     * @return the relative request URI with query string, or {@code null} 
when the request is unavailable
+     */
+    public static String getRelativeRequestPath(HttpServletRequest request) {
+        return UtilHttp.getRelativeRequestPath(request);
+    }
+
+    /**
+     * Extracts the relative request path from a Groovy service binding when an
+     * HTTP request is available.
+     * <p>
+     * Purpose: REST-exposed Groovy services can be invoked through HTTP or
+     * directly through the service engine. Direct service calls do not bind a
+     * servlet request, so this helper centralizes the safe missing-request 
check
+     * while still letting HTTP calls generate pagination links.
+     *
+     * @param binding the Groovy service script binding
+     * @return the relative request URI with query string, or {@code null} 
when no servlet request is bound
+     */
+    public static String getRelativeRequestPath(Binding binding) {
+        if (binding == null || !binding.hasVariable("request")) {
+            return null;
+        }
+        Object request = binding.getVariable("request");
+        return request instanceof HttpServletRequest ? 
getRelativeRequestPath((HttpServletRequest) request) : null;
+    }
+
     /**
      * Validates candidate filter parameters against an optional 
endpoint-defined
      * allowlist while preserving insertion order.
+     * <p>
+     * Purpose: lets REST services share framework filter validation instead of
+     * implementing endpoint-local allowlist checks.
      *
      * @param filters the candidate filter parameters collected from the 
request
      * @param allowedFields the endpoint-supported filter fields, or
@@ -443,13 +546,16 @@ public final class RestApiUtil {
      * @return the validated filter parameters in insertion order
      * @throws IllegalArgumentException when a filter field is unsupported
      */
-    static Map<String, Object> validateFilterParameters(Map<String, ?> 
filters, Set<String> allowedFields) {
+    public static Map<String, Object> validateFilterParameters(Map<String, ?> 
filters, Set<String> allowedFields) {
         return validateFilterParameters(filters, allowedFields, null, null);
     }
 
     /**
      * Validates candidate direct filter parameters against optional 
endpoint-defined
      * policies while preserving insertion order.
+     * <p>
+     * Purpose: lets REST services share filter allowlist, 
repeatable-parameter,
+     * and per-field value validation rules.
      *
      * @param filters the candidate filter parameters collected from the 
request
      * @param allowedFields the endpoint-supported filter fields, or
@@ -460,7 +566,7 @@ public final class RestApiUtil {
      * @return the validated filter parameters in insertion order
      * @throws IllegalArgumentException when a filter field or value is invalid
      */
-    static Map<String, Object> validateFilterParameters(Map<String, ?> 
filters, Set<String> allowedFields,
+    public static Map<String, Object> validateFilterParameters(Map<String, ?> 
filters, Set<String> allowedFields,
             Set<String> repeatableFields, Map<String, FilterValueValidator> 
valueValidators) {
         if (UtilValidate.isEmpty(filters)) {
             return Collections.emptyMap();
diff --git 
a/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/util/RestApiUtilPaginationTest.java
 
b/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/util/RestApiUtilPaginationTest.java
index 262de1c468..020f92f100 100644
--- 
a/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/util/RestApiUtilPaginationTest.java
+++ 
b/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/util/RestApiUtilPaginationTest.java
@@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 
+import java.util.Comparator;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
@@ -11,7 +12,10 @@ import java.util.Set;
 
 import org.apache.ofbiz.base.util.UtilMisc;
 import org.junit.jupiter.api.Test;
+import org.springframework.mock.web.MockHttpServletRequest;
 
+import groovy.lang.Binding;
+import jakarta.servlet.http.HttpServletRequest;
 import jakarta.ws.rs.core.Response;
 
 public final class RestApiUtilPaginationTest {
@@ -106,6 +110,69 @@ public final class RestApiUtilPaginationTest {
         assertEquals("Duplicate sort field: entityName", 
exception.getMessage());
     }
 
+    @Test
+    public void sortsMapRowsByRestSortExpression() {
+        List<Map<String, Object>> rows = List.of(
+                UtilMisc.toMap("itemId", "ITEM-A", "locationId", "LOC1"),
+                UtilMisc.toMap("itemId", "ITEM-C", "locationId", "LOC1"),
+                UtilMisc.toMap("itemId", "ITEM-B", "locationId", "LOC1"));
+
+        List<Map<String, Object>> sortedRows = RestApiUtil.sortMapRows(rows, 
"-itemId",
+                Set.of("itemId", "locationId"), Comparator.comparing(row -> 
(String) row.get("itemId")));
+
+        assertEquals(List.of("ITEM-C", "ITEM-B", "ITEM-A"), 
sortedRows.stream().map(row -> row.get("itemId")).toList());
+    }
+
+    @Test
+    public void rejectsUnsupportedMapRowSortFields() {
+        IllegalArgumentException exception = 
assertThrows(IllegalArgumentException.class, () ->
+                RestApiUtil.sortMapRows(List.of(Map.of("itemId", "ITEM-A")), 
"notAField",
+                        Set.of("itemId"), Comparator.comparing(row -> (String) 
row.get("itemId"))));
+
+        assertEquals("Unsupported sort field: notAField", 
exception.getMessage());
+    }
+
+    @Test
+    public void rejectsUnsupportedMapRowSortFieldsWhenRowsEmpty() {
+        IllegalArgumentException exception = 
assertThrows(IllegalArgumentException.class, () ->
+                RestApiUtil.sortMapRows(List.of(), "notAField",
+                        Set.of("itemId"), Comparator.comparing(row -> (String) 
row.get("itemId"))));
+
+        assertEquals("Unsupported sort field: notAField", 
exception.getMessage());
+    }
+
+    @Test
+    public void returnsMutableSortedMapRows() {
+        List<Map<String, Object>> rows = List.of(Map.of("itemId", "ITEM-A"));
+        List<Map<String, Object>> sortedRows = RestApiUtil.sortMapRows(rows, 
"itemId",
+                Set.of("itemId"), Comparator.comparing(row -> (String) 
row.get("itemId")));
+
+        sortedRows.add(Map.of("itemId", "ITEM-B"));
+
+        assertEquals(2, sortedRows.size());
+    }
+
+    @Test
+    public void pagesAlreadyComputedLists() {
+        List<String> page = RestApiUtil.pageList(List.of("a", "b", "c", "d"), 
1, 2);
+
+        assertEquals(List.of("c", "d"), page);
+    }
+
+    @Test
+    public void returnsEmptyPageWhenComputedPageStartsAfterListEnd() {
+        List<String> page = RestApiUtil.pageList(List.of("a", "b"), 2, 2);
+
+        assertEquals(List.of(), page);
+    }
+
+    @Test
+    public void 
returnsEmptyPageWhenComputedPageIndexOverflowsIntMultiplication() {
+        List<String> page = RestApiUtil.pageList(List.of("a", "b"), 
Integer.MAX_VALUE, 100);
+
+        assertEquals(List.of(), page);
+    }
+
     @Test
     public void serializesAvailableRelationsAsHttpLinkHeader() {
         Map<String, Object> links = new LinkedHashMap<>();
@@ -164,7 +231,7 @@ public final class RestApiUtilPaginationTest {
     public void rejectsRepeatedFilterValuesWhenFieldIsNotRepeatable() {
         IllegalArgumentException exception = 
assertThrows(IllegalArgumentException.class, () ->
                 RestApiUtil.validateFilterParameters(
-                        UtilMisc.toMap("statusId", List.of("PRUN_CREATED", 
"PRUN_RUNNING")),
+                        UtilMisc.toMap("statusId", List.of("STATE_CREATED", 
"STATE_RUNNING")),
                         Set.of("statusId"), null, null));
 
         assertEquals("Filter parameter does not support repeated values: 
statusId", exception.getMessage());
@@ -173,10 +240,10 @@ public final class RestApiUtilPaginationTest {
     @Test
     public void preservesRepeatedFilterValuesWhenFieldIsRepeatable() {
         Map<String, Object> validatedFilters = 
RestApiUtil.validateFilterParameters(
-                UtilMisc.toMap("statusId", List.of("PRUN_CREATED", 
"PRUN_RUNNING")),
+                UtilMisc.toMap("statusId", List.of("STATE_CREATED", 
"STATE_RUNNING")),
                 Set.of("statusId"), Set.of("statusId"), null);
 
-        assertEquals(List.of("PRUN_CREATED", "PRUN_RUNNING"), 
validatedFilters.get("statusId"));
+        assertEquals(List.of("STATE_CREATED", "STATE_RUNNING"), 
validatedFilters.get("statusId"));
     }
 
     @Test
@@ -213,4 +280,38 @@ public final class RestApiUtilPaginationTest {
 
         assertNull(response.getHeaderString("Link"));
     }
+
+    @Test
+    public void buildsRelativeRequestPathWithQueryString() {
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.setRequestURI("/rest/items");
+        request.setQueryString("pageIndex=1&pageSize=1");
+
+        assertEquals("/rest/items?pageIndex=1&pageSize=1", 
RestApiUtil.getRelativeRequestPath(request));
+    }
+
+    @Test
+    public void returnsNullRelativeRequestPathWhenRequestMissing() {
+        assertNull(RestApiUtil.getRelativeRequestPath((HttpServletRequest) 
null));
+    }
+
+    @Test
+    public void buildsRelativeRequestPathFromGroovyBinding() {
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.setRequestURI("/rest/items");
+        request.setQueryString("pageIndex=1&pageSize=1");
+        Binding binding = new Binding(Map.of("request", request));
+
+        assertEquals("/rest/items?pageIndex=1&pageSize=1", 
RestApiUtil.getRelativeRequestPath(binding));
+    }
+
+    @Test
+    public void returnsNullRelativeRequestPathWhenGroovyBindingHasNoRequest() {
+        assertNull(RestApiUtil.getRelativeRequestPath(new Binding()));
+    }
+
+    @Test
+    public void 
returnsNullRelativeRequestPathWhenGroovyBindingHasNonServletRequest() {
+        assertNull(RestApiUtil.getRelativeRequestPath(new 
Binding(Map.of("request", "notARequest"))));
+    }
 }

Reply via email to