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")))); + } }

