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


The following commit(s) were added to refs/heads/trunk by this push:
     new c345678d99 OFBIZ-13457 : Add generic REST collection query, filter, 
sort, and pagination helpers (#1471)
c345678d99 is described below

commit c345678d99c8da4725b3c04b34547243f1455b7a
Author: Divesh Dutta <[email protected]>
AuthorDate: Fri Jul 31 11:01:52 2026 +0530

    OFBIZ-13457 : Add generic REST collection query, filter, sort, and 
pagination helpers (#1471)
    
    ## Jira Ticket:
    
    https://issues.apache.org/jira/browse/OFBIZ-13457
    
    ## Summary
    
    Adds generic framework support in `framework/rest-api` for REST
    collection query handling and paged response construction.
    
    This contribution introduces reusable helpers for:
    - normalized paging parameter parsing
    - generic direct-parameter filter preservation
    - sort expression validation
    - optional endpoint validation for supported sort/filter fields
    - optional endpoint validation for repeated filter values and filter
    value formats
    - top-level pagination metadata
    - body pagination links
    - HTTP `Link` header generation for paged REST responses
    - centralized `400 Bad Request` mapping for malformed or unsupported
    query-contract input
    
    The implementation is intended to be reusable across OFBiz REST
    endpoints while keeping endpoint-specific query policies optional.
    
    ## What’s Included
    
    - support for `pageIndex` / `pageSize`
    - support for OFBiz-compatible aliases `VIEW_INDEX` / `VIEW_SIZE`
    - default paging values and max page size enforcement
    - validation of malformed or invalid paging input
    - preservation of non-reserved query parameters as generic direct filter
    parameters
    - generic sort validation helper for comma-separated sort expressions
    with optional `-` prefix
    - sort validation for:
      - empty sort tokens
      - malformed tokens such as `-`
      - duplicate sort fields
      - unsupported sort fields when an endpoint provides an allowlist
    - optional endpoint validation of allowed filter fields
    - optional endpoint validation of repeated filter values
    - optional endpoint validation of filter value format through per-field
    validators
    - top-level response metadata:
      - `pageIndex`
      - `pageSize`
      - `totalCount`
      - `totalPages`
      - `hasNext`
      -  `previousPageCount`
      -   `nextPageCount`
    - body pagination links:
      - `self`
      - `prev`
      - `next`
      - `first`
      - `last`
    - omission of unavailable links, rather than returning empty
    placeholders
    - omission of links entirely when no request path is available
    - preservation of repeated non-reserved query parameters in generated
    pagination links
    - query parameter decoding and re-encoding for generated pagination
    links
    - HTTP `Link` header generation for paged success responses
    - centralized mapping of query-contract `IllegalArgumentException`
    failures to HTTP `400 Bad Request`
    - focused framework tests covering:
      - paging normalization and defaults
      - malformed paging input
      - sort validation
      - duplicate sort rejection
      - optional sort/filter allowlist validation
      - repeated filter value policy
      - filter value-format validation
      - link generation
      - repeated query parameters
      - special-character encoding
      - HTTP `Link` header behavior
      - HTTP `400` mapping behavior
    
    ## Query Contract Behavior
    
    Current framework support in this PR is aligned to direct query
    parameters such as:
    
    - paging:
      - `?pageIndex=0&pageSize=20`
    - sorting:
      - `?sort=statusId,-createdStamp`
    - filtering:
      - `?facilityId=WebStoreWarehouse&statusId=PRUN_CREATED`
    
    The framework now supports returning `400 Bad Request` when the query
    contract is malformed or unsupported in these areas:
    - invalid paging values
    - malformed sort expressions
    - duplicate sort fields
    - unsupported sort fields when an endpoint supplies an allowlist
    - unsupported filter fields when an endpoint supplies an allowlist
    - repeated filter values when an endpoint does not allow repetition
    - invalid filter value formats when an endpoint supplies a validator
    
    ## Not Included
    
    - no standardized `filter=` grammar yet
    - no generic filtering operators such as `eq`, `ne`, `gt`, `lt`, etc.
    - no generic parser for operator-based filter expressions
    - no cursor-based pagination
    - no framework-enforced endpoint default ordering
    - no framework-mandated sort/filter allowlists for every endpoint
    - no automatic endpoint-specific value/type validation unless the
    endpoint opts into the provided validator hooks
---
 .../ws/rs/spi/impl/GlobalExceptionMapper.java      |  17 +-
 .../org/apache/ofbiz/ws/rs/util/RestApiUtil.java   | 398 ++++++++++++++++++++-
 .../ofbiz/ws/rs/util/RestListResponseBuilder.java  | 222 ++++++++++++
 .../apache/ofbiz/ws/rs/util/RestQueryOptions.java  | 140 ++++++++
 .../ws/rs/spi/impl/GlobalExceptionMapperTest.java  |  40 +++
 .../ws/rs/util/RestApiUtilPaginationTest.java      | 189 ++++++++++
 .../ws/rs/util/RestListResponseBuilderTest.java    | 211 +++++++++++
 .../ofbiz/ws/rs/util/RestQueryOptionsTest.java     |  69 ++++
 framework/rest-api/testdef/rest-apiTests.xml       |  16 +-
 9 files changed, 1293 insertions(+), 9 deletions(-)

diff --git 
a/framework/rest-api/src/main/java/org/apache/ofbiz/ws/rs/spi/impl/GlobalExceptionMapper.java
 
b/framework/rest-api/src/main/java/org/apache/ofbiz/ws/rs/spi/impl/GlobalExceptionMapper.java
index 3423ae359a..ab6c9cb843 100644
--- 
a/framework/rest-api/src/main/java/org/apache/ofbiz/ws/rs/spi/impl/GlobalExceptionMapper.java
+++ 
b/framework/rest-api/src/main/java/org/apache/ofbiz/ws/rs/spi/impl/GlobalExceptionMapper.java
@@ -35,8 +35,10 @@ public class GlobalExceptionMapper implements 
jakarta.ws.rs.ext.ExceptionMapper<
      * {@inheritDoc}
      *
      * <p>Maps the throwable to an HTTP error response. If the throwable is a
-     * {@link WebApplicationException}, its own HTTP status is used; all other
-     * throwables result in HTTP 500 Internal Server Error.</p>
+     * {@link WebApplicationException}, its own HTTP status is used. 
Query-contract
+     * validation failures reported as {@link IllegalArgumentException} are 
mapped
+     * to HTTP 400 Bad Request. All other throwables result in HTTP 500 
Internal
+     * Server Error.</p>
      */
     @Override
     public Response toResponse(Throwable throwable) {
@@ -44,9 +46,14 @@ public class GlobalExceptionMapper implements 
jakarta.ws.rs.ext.ExceptionMapper<
         if (Debug.verboseOn()) {
             throwable.printStackTrace();
         }
-        Response.StatusType type = (throwable instanceof 
WebApplicationException
-                    ? ((WebApplicationException) 
throwable).getResponse().getStatusInfo()
-                    : Response.Status.INTERNAL_SERVER_ERROR);
+        Response.StatusType type;
+        if (throwable instanceof WebApplicationException) {
+            type = ((WebApplicationException) 
throwable).getResponse().getStatusInfo();
+        } else if (throwable instanceof IllegalArgumentException) {
+            type = Response.Status.BAD_REQUEST;
+        } else {
+            type = Response.Status.INTERNAL_SERVER_ERROR;
+        }
 
         Error error = new Error(type.getStatusCode(), type.getReasonPhrase(), 
throwable.getMessage());
         return 
Response.status(type.getStatusCode()).entity(error).type(MediaType.APPLICATION_JSON).build();
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 ef9637b68a..2bb33871cd 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
@@ -18,12 +18,20 @@
  
*******************************************************************************/
 package org.apache.ofbiz.ws.rs.util;
 
+import java.io.UnsupportedEncodingException;
+import java.net.URLDecoder;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
 import java.util.LinkedList;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
+import java.util.Set;
 
 import org.apache.ofbiz.base.util.UtilProperties;
 import org.apache.ofbiz.base.util.UtilValidate;
@@ -40,6 +48,7 @@ import jakarta.ws.rs.core.Response.ResponseBuilder;
 public final class RestApiUtil {
 
     private static final String DEFAULT_MSG_UI_LABEL_RESOURCE = "ApiUiLabels";
+    private static final String QUERY_STRING_SEPARATOR = "&";
 
     private RestApiUtil() {
 
@@ -54,7 +63,12 @@ public final class RestApiUtil {
      */
     public static Response success(String message, Object data) {
         Success success = new Success(Response.Status.OK.getStatusCode(), 
Response.Status.OK.getReasonPhrase(), message, data);
-        return 
Response.status(Response.Status.OK).type(MediaType.APPLICATION_JSON).entity(success).build();
+        ResponseBuilder builder = 
Response.status(Response.Status.OK).type(MediaType.APPLICATION_JSON).entity(success);
+        String linkHeaderValue = getPaginationLinkHeaderValue(data);
+        if (UtilValidate.isNotEmpty(linkHeaderValue)) {
+            builder.header("Link", linkHeaderValue);
+        }
+        return builder.build();
     }
 
     /**
@@ -179,4 +193,386 @@ public final class RestApiUtil {
         error = error.replace("${service}", serviceName);
         return error;
     }
+
+    /**
+     * Returns the first non-empty parameter value found for the provided 
aliases,
+     * matching keys case-insensitively.
+     *
+     * @param parameters the parameter map to search
+     * @param aliases the accepted parameter names in lookup order
+     * @return the first non-empty value, or {@code null} when none is present
+     */
+    static Object getParameterValueIgnoreCase(Map<String, ?> parameters, 
String... aliases) {
+        if (UtilValidate.isEmpty(parameters) || aliases == null || 
aliases.length == 0) {
+            return null;
+        }
+        for (String alias : aliases) {
+            for (Map.Entry<String, ?> entry : parameters.entrySet()) {
+                if (entry.getKey() != null && 
entry.getKey().equalsIgnoreCase(alias)
+                        && UtilValidate.isNotEmpty(entry.getValue())) {
+                    return entry.getValue();
+                }
+            }
+        }
+        return null;
+    }
+
+    /**
+     * Returns {@code true} when the given parameter name matches any reserved
+     * name case-insensitively.
+     *
+     * @param parameterName the parameter name to inspect
+     * @param reservedNames the reserved names to compare against
+     * @return {@code true} when the parameter name is reserved
+     */
+    static boolean isReservedParameter(String parameterName, Set<String> 
reservedNames) {
+        if (parameterName == null || UtilValidate.isEmpty(reservedNames)) {
+            return false;
+        }
+        for (String reservedName : reservedNames) {
+            if (parameterName.equalsIgnoreCase(reservedName)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * Parses a request path or query string into an insertion-ordered 
parameter list.
+     * When the input does not contain a query string, an empty map is 
returned.
+     *
+     * @param requestPath the full request path or query string
+     * @return a list containing decoded query parameters in encounter order
+     */
+    static List<QueryParameter> extractQueryParameters(String requestPath) {
+        List<QueryParameter> parameters = new ArrayList<>();
+        if (UtilValidate.isEmpty(requestPath)) {
+            return parameters;
+        }
+
+        int querySeparatorIndex = requestPath.indexOf('?');
+        if (querySeparatorIndex < 0) {
+            return parameters;
+        }
+        String queryString = requestPath.substring(querySeparatorIndex + 1);
+        if (UtilValidate.isEmpty(queryString)) {
+            return parameters;
+        }
+
+        for (String entry : queryString.split(QUERY_STRING_SEPARATOR)) {
+            if (UtilValidate.isEmpty(entry)) {
+                continue;
+            }
+
+            int assignmentIndex = entry.indexOf('=');
+            String key = assignmentIndex >= 0 ? entry.substring(0, 
assignmentIndex) : entry;
+            String value = assignmentIndex >= 0 ? 
entry.substring(assignmentIndex + 1) : "";
+            String decodedKey = decodeQueryValue(key);
+            String decodedValue = decodeQueryValue(value);
+            if (UtilValidate.isNotEmpty(decodedKey)) {
+                parameters.add(new QueryParameter(decodedKey, decodedValue));
+            }
+        }
+        return parameters;
+    }
+
+    /**
+     * Encodes query parameters for safe inclusion in a URL query string while
+     * preserving encounter order and repeated keys.
+     *
+     * @param parameters decoded query parameters
+     * @return an encoded query string, or an empty string when no parameters 
exist
+     */
+    static String encodeQueryParameters(List<QueryParameter> parameters) {
+        if (UtilValidate.isEmpty(parameters)) {
+            return "";
+        }
+        StringBuilder queryBuilder = new StringBuilder();
+        boolean first = true;
+        for (QueryParameter parameter : parameters) {
+            if (!first) {
+                queryBuilder.append(QUERY_STRING_SEPARATOR);
+            }
+            
queryBuilder.append(encodeQueryValue(parameter.getName())).append('=')
+                    .append(encodeQueryValue(parameter.getValue()));
+            first = false;
+        }
+        return queryBuilder.toString();
+    }
+
+    /**
+     * Builds a map representation of a REST link that matches the existing
+     * serializer contract of {@code href} plus optional link parameters.
+     *
+     * @param href the link target
+     * @param params optional link parameters such as {@code rel}
+     * @return an insertion-ordered map suitable for JSON serialization
+     */
+    static Map<String, Object> makeLinkMap(String href, Map<String, String> 
params) {
+        Map<String, Object> link = new LinkedHashMap<>();
+        if (UtilValidate.isEmpty(href)) {
+            return link;
+        }
+        link.put("href", href);
+        if (UtilValidate.isNotEmpty(params)) {
+            params.forEach((key, value) -> {
+                if (UtilValidate.isNotEmpty(key) && 
UtilValidate.isNotEmpty(value)) {
+                    link.put(key, value);
+                }
+            });
+        }
+        return link;
+    }
+
+    /**
+     * Validates a comma-separated sort expression against the endpoint fields
+     * allowed for sorting and returns the accepted normalized tokens in order.
+     *
+     * @param sortExpression the requested sort expression
+     * @param allowedFields the endpoint-supported sortable fields, or
+     *        {@code null}/empty to apply syntax-only validation
+     * @return the validated sort tokens, or an empty list when no sort was 
requested
+     * @throws IllegalArgumentException when a token is malformed or a field 
is unsupported
+     */
+    static List<String> validateSortFields(String sortExpression, Set<String> 
allowedFields) {
+        if (UtilValidate.isEmpty(sortExpression)) {
+            return Collections.emptyList();
+        }
+
+        List<String> validatedFields = new ArrayList<>();
+        Set<String> seenFields = new HashSet<>();
+        for (String token : sortExpression.split(",", -1)) {
+            String candidate = token.trim();
+            if (UtilValidate.isEmpty(candidate)) {
+                throw new IllegalArgumentException("Sort expression contains 
an empty field");
+            }
+            if ("-".equals(candidate)) {
+                throw new IllegalArgumentException("Sort expression contains a 
malformed field");
+            }
+
+            String normalizedField = candidate.startsWith("-") ? 
candidate.substring(1) : candidate;
+            if (!seenFields.add(normalizedField)) {
+                throw new IllegalArgumentException("Duplicate sort field: " + 
normalizedField);
+            }
+            if (UtilValidate.isNotEmpty(allowedFields) && 
!allowedFields.contains(normalizedField)) {
+                throw new IllegalArgumentException("Unsupported sort field: " 
+ normalizedField);
+            }
+            validatedFields.add(candidate);
+        }
+        return validatedFields;
+    }
+
+    /**
+     * Validates candidate filter parameters against an optional 
endpoint-defined
+     * allowlist while preserving insertion order.
+     *
+     * @param filters the candidate filter parameters collected from the 
request
+     * @param allowedFields the endpoint-supported filter fields, or
+     *        {@code null}/empty to skip field-level validation
+     * @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) {
+        return validateFilterParameters(filters, allowedFields, null, null);
+    }
+
+    /**
+     * Validates candidate direct filter parameters against optional 
endpoint-defined
+     * policies while preserving insertion order.
+     *
+     * @param filters the candidate filter parameters collected from the 
request
+     * @param allowedFields the endpoint-supported filter fields, or
+     *        {@code null}/empty to skip field-level validation
+     * @param repeatableFields the fields allowed to appear more than once, or
+     *        {@code null}/empty to reject repeated values for all fields
+     * @param valueValidators optional per-field validators for scalar or 
repeated values
+     * @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,
+            Set<String> repeatableFields, Map<String, FilterValueValidator> 
valueValidators) {
+        if (UtilValidate.isEmpty(filters)) {
+            return Collections.emptyMap();
+        }
+
+        Map<String, Object> validatedFilters = new LinkedHashMap<>();
+        for (Map.Entry<String, ?> entry : filters.entrySet()) {
+            if (UtilValidate.isEmpty(entry.getKey()) || 
UtilValidate.isEmpty(entry.getValue())) {
+                continue;
+            }
+            if (UtilValidate.isNotEmpty(allowedFields) && 
!allowedFields.contains(entry.getKey())) {
+                throw new IllegalArgumentException("Unsupported filter field: 
" + entry.getKey());
+            }
+            validateFilterValues(entry.getKey(), entry.getValue(), 
repeatableFields, valueValidators);
+            validatedFilters.put(entry.getKey(), entry.getValue());
+        }
+        return validatedFilters;
+    }
+
+    /**
+     * Serializes body-style pagination links into an RFC-style HTTP Link 
header
+     * value while preserving the existing body-link order.
+     *
+     * @param links the response-body pagination links keyed by relation
+     * @return the HTTP Link header value, or {@code null} when no complete 
relations exist
+     */
+    static String toLinkHeaderValue(Map<String, Object> links) {
+        if (UtilValidate.isEmpty(links)) {
+            return null;
+        }
+
+        List<String> headerParts = new ArrayList<>();
+        for (Map.Entry<String, Object> entry : links.entrySet()) {
+            Map<String, Object> link = castLinkMap(entry.getValue());
+            Object href = link.get("href");
+            Object rel = link.get("rel");
+            if (UtilValidate.isNotEmpty(href) && UtilValidate.isNotEmpty(rel)) 
{
+                headerParts.add("<" + href + ">; rel=\"" + rel + "\"");
+            }
+        }
+        return headerParts.isEmpty() ? null : String.join(", ", headerParts);
+    }
+
+    @SuppressWarnings("unchecked")
+    private static String getPaginationLinkHeaderValue(Object data) {
+        if (!(data instanceof Map<?, ?> dataMap)) {
+            return null;
+        }
+        Object links = dataMap.get("links");
+        return links instanceof Map<?, ?> ? toLinkHeaderValue((Map<String, 
Object>) links) : null;
+    }
+
+    /**
+     * Builds a list result from already normalized REST query options.
+     *
+     * @param collectionName the response property name for the collection
+     * @param collectionData the collection payload
+     * @param queryOptions the normalized REST query options
+     * @param totalCount the total matching record count
+     * @param requestPath the originating request path
+     * @return a map containing collection data, pagination metadata, and links
+     */
+    public static Map<String, Object> getPagedResult(String collectionName, 
Object collectionData, RestQueryOptions queryOptions,
+            long totalCount, String requestPath) {
+        return RestListResponseBuilder.forList(collectionName, collectionData)
+                .pageIndex(queryOptions.getPageIndex())
+                .pageSize(queryOptions.getPageSize())
+                .totalCount(totalCount)
+                .requestPath(requestPath)
+                .build();
+    }
+
+    /**
+     * Builds a list result from explicit paging values.
+     *
+     * @param collectionName the response property name for the collection
+     * @param collectionData the collection payload
+     * @param pageIndex the zero-based page index
+     * @param pageSize the page size
+     * @param totalCount the total matching record count
+     * @param requestPath the originating request path
+     * @return a map containing collection data, pagination metadata, and links
+     */
+    public static Map<String, Object> getCollectionResult(String 
collectionName, Object collectionData, int pageIndex, int pageSize,
+            long totalCount, String requestPath) {
+        return RestListResponseBuilder.forList(collectionName, collectionData)
+                .pageIndex(pageIndex)
+                .pageSize(pageSize)
+                .totalCount(totalCount)
+                .requestPath(requestPath)
+                .build();
+    }
+
+    private static String decodeQueryValue(String value) {
+        try {
+            return URLDecoder.decode(value, StandardCharsets.UTF_8.name());
+        } catch (UnsupportedEncodingException e) {
+            throw new IllegalStateException("UTF-8 must always be available", 
e);
+        }
+    }
+
+    @SuppressWarnings("unchecked")
+    private static Map<String, Object> castLinkMap(Object value) {
+        return value instanceof Map<?, ?> ? (Map<String, Object>) value : 
Collections.emptyMap();
+    }
+
+    private static void validateFilterValues(String fieldName, Object value, 
Set<String> repeatableFields,
+            Map<String, FilterValueValidator> valueValidators) {
+        if (value instanceof List<?> values) {
+            if (values.size() > 1 && (UtilValidate.isEmpty(repeatableFields) 
|| !repeatableFields.contains(fieldName))) {
+                throw new IllegalArgumentException("Filter parameter does not 
support repeated values: " + fieldName);
+            }
+            for (Object item : values) {
+                validateFilterValue(fieldName, item, valueValidators);
+            }
+            return;
+        }
+        validateFilterValue(fieldName, value, valueValidators);
+    }
+
+    private static void validateFilterValue(String fieldName, Object value, 
Map<String, FilterValueValidator> valueValidators) {
+        if (UtilValidate.isEmpty(value) || 
UtilValidate.isEmpty(valueValidators)) {
+            return;
+        }
+        FilterValueValidator validator = valueValidators.get(fieldName);
+        if (validator != null) {
+            validator.validate(fieldName, value);
+        }
+    }
+
+    private static String encodeQueryValue(String value) {
+        try {
+            return URLEncoder.encode(value, 
StandardCharsets.UTF_8.name()).replace("+", "%20");
+        } catch (UnsupportedEncodingException e) {
+            throw new IllegalStateException("UTF-8 must always be available", 
e);
+        }
+    }
+
+    /**
+     * Validates the direct query-parameter values accepted for a filter field.
+     */
+    @FunctionalInterface
+    public interface FilterValueValidator {
+
+        /**
+         * Validates a single filter value for the given field.
+         *
+         * @param fieldName the filter field name
+         * @param value the direct request value to validate
+         * @throws IllegalArgumentException when the value is not supported
+         */
+        void validate(String fieldName, Object value);
+    }
+
+    /**
+     * Represents a decoded query parameter while preserving encounter order 
and
+     * repeated keys for downstream link generation.
+     */
+    static final class QueryParameter {
+        private final String name;
+        private final String value;
+
+        QueryParameter(String name, String value) {
+            this.name = name;
+            this.value = value;
+        }
+
+        /**
+         * Returns the decoded parameter name.
+         *
+         * @return the decoded parameter name
+         */
+        public String getName() {
+            return name;
+        }
+
+        /**
+         * Returns the decoded parameter value.
+         *
+         * @return the decoded parameter value
+         */
+        public String getValue() {
+            return value;
+        }
+    }
 }
diff --git 
a/framework/rest-api/src/main/java/org/apache/ofbiz/ws/rs/util/RestListResponseBuilder.java
 
b/framework/rest-api/src/main/java/org/apache/ofbiz/ws/rs/util/RestListResponseBuilder.java
new file mode 100644
index 0000000000..d1fe5dfac6
--- /dev/null
+++ 
b/framework/rest-api/src/main/java/org/apache/ofbiz/ws/rs/util/RestListResponseBuilder.java
@@ -0,0 +1,222 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 org.apache.ofbiz.ws.rs.util;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import org.apache.ofbiz.base.util.UtilMisc;
+import org.apache.ofbiz.base.util.UtilValidate;
+
+/**
+ * Builds a generic list response payload containing pagination metadata, list
+ * data, and REST-style navigation links.
+ */
+public final class RestListResponseBuilder {
+
+    private final String collectionName;
+    private final Object collectionData;
+    private int pageIndex = RestQueryOptions.DEFAULT_PAGE_INDEX;
+    private int pageSize = RestQueryOptions.DEFAULT_PAGE_SIZE;
+    private long totalCount;
+    private String requestPath;
+
+    private RestListResponseBuilder(String collectionName, Object 
collectionData) {
+        if (UtilValidate.isEmpty(collectionName)) {
+            throw new IllegalArgumentException("collectionName must not be 
empty");
+        }
+        this.collectionName = collectionName;
+        this.collectionData = collectionData;
+    }
+
+    /**
+     * Creates a builder for a named collection payload.
+     *
+     * @param collectionName the JSON property name for the collection payload
+     * @param collectionData the collection payload value
+     * @return a new response builder
+     */
+    public static RestListResponseBuilder forList(String collectionName, 
Object collectionData) {
+        return new RestListResponseBuilder(collectionName, collectionData);
+    }
+
+    /**
+     * Sets the zero-based page index.
+     *
+     * @param pageIndex the current page index
+     * @return this builder
+     */
+    public RestListResponseBuilder pageIndex(int pageIndex) {
+        this.pageIndex = validatePageIndex(pageIndex);
+        return this;
+    }
+
+    /**
+     * Sets the page size.
+     *
+     * @param pageSize the current page size
+     * @return this builder
+     */
+    public RestListResponseBuilder pageSize(int pageSize) {
+        this.pageSize = validatePageSize(pageSize);
+        return this;
+    }
+
+    /**
+     * Sets the total number of matching records.
+     *
+     * @param totalCount the total matching record count
+     * @return this builder
+     */
+    public RestListResponseBuilder totalCount(long totalCount) {
+        this.totalCount = Math.max(0, totalCount);
+        return this;
+    }
+
+    /**
+     * Sets the originating request path used to construct pagination links.
+     *
+     * @param requestPath the request path, optionally including a query string
+     * @return this builder
+     */
+    public RestListResponseBuilder requestPath(String requestPath) {
+        this.requestPath = requestPath;
+        return this;
+    }
+
+    /**
+     * Builds the collection response payload.
+     *
+     * @return a map containing pagination metadata, collection data, and links
+     */
+    public Map<String, Object> build() {
+        Map<String, Object> response = new LinkedHashMap<>();
+        response.put("pageIndex", pageIndex);
+        response.put("pageSize", pageSize);
+        response.put("totalCount", totalCount);
+        response.put("totalPages", getTotalPages());
+        response.put("hasNext", hasNext());
+        response.put("previousPageCount", getPreviousPageCount());
+        response.put("nextPageCount", getNextPageCount());
+        response.put(collectionName, collectionData);
+
+        Map<String, Object> links = buildLinks();
+        if (UtilValidate.isNotEmpty(links)) {
+            response.put("links", links);
+        }
+        return response;
+    }
+
+    private boolean hasNext() {
+        return totalCount > ((long) pageIndex + 1L) * pageSize;
+    }
+
+    private long getTotalPages() {
+        if (totalCount <= 0) {
+            return 0L;
+        }
+        return (totalCount / pageSize) + ((totalCount % pageSize == 0) ? 0L : 
1L);
+    }
+
+    private long getPreviousPageCount() {
+        return Math.max(0L, pageIndex);
+    }
+
+    private long getNextPageCount() {
+        return Math.max(getTotalPages() - pageIndex - 1L, 0L);
+    }
+
+    private Map<String, Object> buildLinks() {
+        Map<String, Object> links = new LinkedHashMap<>();
+        if (UtilValidate.isEmpty(requestPath)) {
+            return links;
+        }
+
+        String basePath = getBasePath(requestPath);
+        if (UtilValidate.isEmpty(basePath)) {
+            return links;
+        }
+
+        long totalPages = getTotalPages();
+        links.put("self", RestApiUtil.makeLinkMap(buildPageHref(basePath, 
pageIndex), UtilMisc.toMap("rel", "self")));
+        if (totalPages > 0) {
+            links.put("first", RestApiUtil.makeLinkMap(buildPageHref(basePath, 
0), UtilMisc.toMap("rel", "first")));
+            if (pageIndex > 0) {
+                links.put("prev", 
RestApiUtil.makeLinkMap(buildPageHref(basePath, pageIndex - 1), 
UtilMisc.toMap("rel", "prev")));
+            }
+            Integer nextPageIndex = getNextPageIndex();
+            if (hasNext() && nextPageIndex != null) {
+                links.put("next", 
RestApiUtil.makeLinkMap(buildPageHref(basePath, nextPageIndex), 
UtilMisc.toMap("rel", "next")));
+            }
+            Integer lastPageIndex = getLastPageIndex();
+            if (lastPageIndex != null) {
+                links.put("last", 
RestApiUtil.makeLinkMap(buildPageHref(basePath, lastPageIndex), 
UtilMisc.toMap("rel", "last")));
+            }
+        }
+        return links;
+    }
+
+    private Integer getLastPageIndex() {
+        long lastPageIndex = getTotalPages() - 1L;
+        if (lastPageIndex > Integer.MAX_VALUE) {
+            return null;
+        }
+        return (int) lastPageIndex;
+    }
+
+    private Integer getNextPageIndex() {
+        if (pageIndex == Integer.MAX_VALUE) {
+            return null;
+        }
+        return pageIndex + 1;
+    }
+
+    private String buildPageHref(String basePath, int targetPageIndex) {
+        List<RestApiUtil.QueryParameter> queryParameters = new 
ArrayList<>(RestApiUtil.extractQueryParameters(requestPath));
+        queryParameters.removeIf(parameter ->
+                RestApiUtil.isReservedParameter(parameter.getName(), 
Set.of("pageIndex", "VIEW_INDEX", "pageSize", "VIEW_SIZE")));
+        queryParameters.add(new RestApiUtil.QueryParameter("pageIndex", 
Integer.toString(targetPageIndex)));
+        queryParameters.add(new RestApiUtil.QueryParameter("pageSize", 
Integer.toString(pageSize)));
+
+        return new 
StringBuilder(basePath).append('?').append(RestApiUtil.encodeQueryParameters(queryParameters)).toString();
+    }
+
+    private static String getBasePath(String path) {
+        int querySeparatorIndex = path.indexOf('?');
+        String basePath = querySeparatorIndex >= 0 ? path.substring(0, 
querySeparatorIndex) : path;
+        return UtilValidate.isEmpty(basePath) ? null : basePath;
+    }
+
+    private static int validatePageIndex(int pageIndex) {
+        if (pageIndex < 0) {
+            throw new IllegalArgumentException("pageIndex must be greater than 
or equal to 0");
+        }
+        return pageIndex;
+    }
+
+    private static int validatePageSize(int pageSize) {
+        if (pageSize < 1 || pageSize > RestQueryOptions.MAX_PAGE_SIZE) {
+            throw new IllegalArgumentException("pageSize must be between 1 and 
100");
+        }
+        return pageSize;
+    }
+}
diff --git 
a/framework/rest-api/src/main/java/org/apache/ofbiz/ws/rs/util/RestQueryOptions.java
 
b/framework/rest-api/src/main/java/org/apache/ofbiz/ws/rs/util/RestQueryOptions.java
new file mode 100644
index 0000000000..c12755cd89
--- /dev/null
+++ 
b/framework/rest-api/src/main/java/org/apache/ofbiz/ws/rs/util/RestQueryOptions.java
@@ -0,0 +1,140 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 org.apache.ofbiz.ws.rs.util;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Set;
+
+import org.apache.ofbiz.base.util.UtilMisc;
+import org.apache.ofbiz.base.util.UtilValidate;
+
+/**
+ * Normalizes REST list query parameters into reusable paging, sorting, and
+ * filter options for framework-level handlers.
+ */
+public final class RestQueryOptions {
+
+    public static final int DEFAULT_PAGE_INDEX = 0;
+    public static final int DEFAULT_PAGE_SIZE = 20;
+    public static final int MAX_PAGE_SIZE = 100;
+
+    private static final Set<String> RESERVED_PARAMETERS = Set.of(
+            "pageIndex", "VIEW_INDEX", "pageSize", "VIEW_SIZE", "sort", 
"orderBy");
+
+    private final int pageIndex;
+    private final int pageSize;
+    private final String sort;
+    private final Map<String, Object> filters;
+
+    private RestQueryOptions(int pageIndex, int pageSize, String sort, 
Map<String, Object> filters) {
+        this.pageIndex = pageIndex;
+        this.pageSize = pageSize;
+        this.sort = sort;
+        this.filters = Collections.unmodifiableMap(new 
LinkedHashMap<>(filters));
+    }
+
+    /**
+     * Creates a normalized collection query from request parameters.
+     *
+     * @param parameters raw request parameters
+     * @return a normalized query object with paging, sorting, and filters
+     * @throws IllegalArgumentException when paging values are invalid
+     */
+    public static RestQueryOptions fromParameters(Map<String, ?> parameters) {
+        Map<String, Object> filters = new LinkedHashMap<>();
+        if (UtilValidate.isNotEmpty(parameters)) {
+            for (Map.Entry<String, ?> entry : parameters.entrySet()) {
+                if (RestApiUtil.isReservedParameter(entry.getKey(), 
RESERVED_PARAMETERS)
+                        || UtilValidate.isEmpty(entry.getValue())) {
+                    continue;
+                }
+                filters.put(entry.getKey(), entry.getValue());
+            }
+        }
+
+        Object pageIndexParam = 
RestApiUtil.getParameterValueIgnoreCase(parameters, "pageIndex", "VIEW_INDEX");
+        Object pageSizeParam = 
RestApiUtil.getParameterValueIgnoreCase(parameters, "pageSize", "VIEW_SIZE");
+        Integer pageIndexValue = UtilMisc.toIntegerObject(pageIndexParam);
+        Integer pageSizeValue = UtilMisc.toIntegerObject(pageSizeParam);
+        if (UtilValidate.isNotEmpty(pageIndexParam) && pageIndexValue == null) 
{
+            throw new IllegalArgumentException("pageIndex must be a valid 
integer");
+        }
+        if (UtilValidate.isNotEmpty(pageSizeParam) && pageSizeValue == null) {
+            throw new IllegalArgumentException("pageSize must be a valid 
integer");
+        }
+        int pageIndex = pageIndexValue != null ? pageIndexValue : 
DEFAULT_PAGE_INDEX;
+        int pageSize = pageSizeValue != null ? pageSizeValue : 
DEFAULT_PAGE_SIZE;
+        if (pageIndex < 0) {
+            throw new IllegalArgumentException("pageIndex must be greater than 
or equal to 0");
+        }
+        if (pageSize < 1 || pageSize > MAX_PAGE_SIZE) {
+            throw new IllegalArgumentException("pageSize must be between 1 and 
100");
+        }
+
+        String sort = null;
+        Object sortValue = RestApiUtil.getParameterValueIgnoreCase(parameters, 
"sort", "orderBy");
+        if (UtilValidate.isNotEmpty(sortValue)) {
+            sort = sortValue.toString().trim();
+            if (UtilValidate.isEmpty(sort)) {
+                sort = null;
+            }
+        }
+
+        return new RestQueryOptions(pageIndex, pageSize, sort, filters);
+    }
+
+    /**
+     * Returns the zero-based page index.
+     *
+     * @return the current page index
+     */
+    public int getPageIndex() {
+        return pageIndex;
+    }
+
+    /**
+     * Returns the requested page size.
+     *
+     * @return the page size
+     */
+    public int getPageSize() {
+        return pageSize;
+    }
+
+    /**
+     * Returns the normalized sort expression, when present.
+     *
+     * @return the sort expression, or {@code null} when not requested
+     */
+    public String getSort() {
+        return sort;
+    }
+
+    /**
+     * Returns the remaining non-reserved request parameters as filters in
+     * insertion order.
+     *
+     * @return immutable filter parameters
+     */
+    public Map<String, Object> getFilters() {
+        return filters;
+    }
+}
diff --git 
a/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/spi/impl/GlobalExceptionMapperTest.java
 
b/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/spi/impl/GlobalExceptionMapperTest.java
new file mode 100644
index 0000000000..423ba68e30
--- /dev/null
+++ 
b/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/spi/impl/GlobalExceptionMapperTest.java
@@ -0,0 +1,40 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 org.apache.ofbiz.ws.rs.spi.impl;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.apache.ofbiz.ws.rs.response.Error;
+import org.junit.jupiter.api.Test;
+
+import jakarta.ws.rs.core.Response;
+
+public final class GlobalExceptionMapperTest {
+
+    @Test
+    public void mapsIllegalArgumentExceptionToBadRequest() {
+        GlobalExceptionMapper mapper = new GlobalExceptionMapper();
+
+        Response response = mapper.toResponse(new 
IllegalArgumentException("Invalid query contract"));
+
+        assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), 
response.getStatus());
+        assertEquals(Response.Status.BAD_REQUEST.getReasonPhrase(), 
response.getStatusInfo().getReasonPhrase());
+        assertEquals("Invalid query contract", ((Error) 
response.getEntity()).getErrorMessage());
+    }
+}
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
new file mode 100644
index 0000000000..cf2c676270
--- /dev/null
+++ 
b/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/util/RestApiUtilPaginationTest.java
@@ -0,0 +1,189 @@
+package org.apache.ofbiz.ws.rs.util;
+
+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.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import org.apache.ofbiz.base.util.UtilMisc;
+import org.junit.jupiter.api.Test;
+
+import jakarta.ws.rs.core.Response;
+
+public final class RestApiUtilPaginationTest {
+
+    @Test
+    public void validatesAllowedSortFields() {
+        List<String> validatedFields = 
RestApiUtil.validateSortFields("fieldOne,-fieldTwo",
+                Set.of("fieldOne", "fieldTwo", "fieldThree"));
+
+        assertEquals(List.of("fieldOne", "-fieldTwo"), validatedFields);
+    }
+
+    @Test
+    public void rejectsUnsupportedSortFields() {
+        IllegalArgumentException exception = 
assertThrows(IllegalArgumentException.class, () ->
+                RestApiUtil.validateSortFields("fieldFour", Set.of("fieldOne", 
"fieldTwo")));
+
+        assertEquals("Unsupported sort field: fieldFour", 
exception.getMessage());
+    }
+
+    @Test
+    public void validatesSortSyntaxWithoutAllowlist() {
+        List<String> validatedFields = 
RestApiUtil.validateSortFields("fieldOne,-fieldFour", null);
+
+        assertEquals(List.of("fieldOne", "-fieldFour"), validatedFields);
+    }
+
+    @Test
+    public void rejectsMalformedSortExpressionsWithEmptySegments() {
+        IllegalArgumentException exception = 
assertThrows(IllegalArgumentException.class, () ->
+                RestApiUtil.validateSortFields("fieldOne,,fieldTwo", 
Set.of("fieldOne", "fieldTwo")));
+
+        assertEquals("Sort expression contains an empty field", 
exception.getMessage());
+    }
+
+    @Test
+    public void rejectsMalformedSortExpressionsWithTrailingEmptySegment() {
+        IllegalArgumentException exception = 
assertThrows(IllegalArgumentException.class, () ->
+                RestApiUtil.validateSortFields("fieldOne,", Set.of("fieldOne", 
"fieldTwo")));
+
+        assertEquals("Sort expression contains an empty field", 
exception.getMessage());
+    }
+
+    @Test
+    public void 
rejectsMalformedSortExpressionsWithMultipleTrailingEmptySegments() {
+        IllegalArgumentException exception = 
assertThrows(IllegalArgumentException.class, () ->
+                RestApiUtil.validateSortFields("fieldOne,,", 
Set.of("fieldOne", "fieldTwo")));
+
+        assertEquals("Sort expression contains an empty field", 
exception.getMessage());
+    }
+
+    @Test
+    public void rejectsMalformedSortExpressionsWithBareDescendingPrefix() {
+        IllegalArgumentException exception = 
assertThrows(IllegalArgumentException.class, () ->
+                RestApiUtil.validateSortFields("-", Set.of("fieldOne", 
"fieldTwo")));
+
+        assertEquals("Sort expression contains a malformed field", 
exception.getMessage());
+    }
+
+    @Test
+    public void rejectsDuplicateSortFields() {
+        IllegalArgumentException exception = 
assertThrows(IllegalArgumentException.class, () ->
+                RestApiUtil.validateSortFields("fieldOne,-fieldOne", 
Set.of("fieldOne", "fieldTwo")));
+
+        assertEquals("Duplicate sort field: fieldOne", exception.getMessage());
+    }
+
+    @Test
+    public void serializesAvailableRelationsAsHttpLinkHeader() {
+        Map<String, Object> links = new LinkedHashMap<>();
+        links.put("first", 
RestApiUtil.makeLinkMap("/rest/items?pageIndex=0&pageSize=20", Map.of("rel", 
"first")));
+        links.put("next", 
RestApiUtil.makeLinkMap("/rest/items?pageIndex=2&pageSize=20", Map.of("rel", 
"next")));
+
+        String header = RestApiUtil.toLinkHeaderValue(links);
+
+        assertEquals("</rest/items?pageIndex=0&pageSize=20>; rel=\"first\", "
+                + "</rest/items?pageIndex=2&pageSize=20>; rel=\"next\"", 
header);
+    }
+
+    @Test
+    public void omitsIncompleteRelationsFromHttpLinkHeader() {
+        Map<String, Object> links = new LinkedHashMap<>();
+        links.put("self", 
RestApiUtil.makeLinkMap("/rest/items?pageIndex=0&pageSize=20", Map.of("rel", 
"self")));
+        links.put("ignored", Map.of("href", 
"/rest/items?pageIndex=1&pageSize=20"));
+
+        String header = RestApiUtil.toLinkHeaderValue(links);
+
+        assertEquals("</rest/items?pageIndex=0&pageSize=20>; rel=\"self\"", 
header);
+    }
+
+    @Test
+    public void returnsNullLinkHeaderWhenNoRelationsAvailable() {
+        assertNull(RestApiUtil.toLinkHeaderValue(null));
+        assertNull(RestApiUtil.toLinkHeaderValue(Map.of()));
+    }
+
+    @Test
+    public void validatesAllowedFilterFields() {
+        Map<String, Object> validatedFilters = 
RestApiUtil.validateFilterParameters(
+                Map.of("filterOne", "valueOne", "filterTwo", "valueTwo"), 
Set.of("filterOne", "filterTwo"));
+
+        assertEquals(Map.of("filterOne", "valueOne", "filterTwo", "valueTwo"), 
validatedFilters);
+    }
+
+    @Test
+    public void preservesFiltersWhenAllowlistMissing() {
+        Map<String, Object> validatedFilters = 
RestApiUtil.validateFilterParameters(
+                Map.of("filterOne", "valueOne", "filterThree", "valueThree"), 
null);
+
+        assertEquals(Map.of("filterOne", "valueOne", "filterThree", 
"valueThree"), validatedFilters);
+    }
+
+    @Test
+    public void rejectsUnsupportedFilterFields() {
+        IllegalArgumentException exception = 
assertThrows(IllegalArgumentException.class, () ->
+                RestApiUtil.validateFilterParameters(
+                        Map.of("filterOne", "valueOne", "filterThree", 
"valueThree"), Set.of("filterOne", "filterTwo")));
+
+        assertEquals("Unsupported filter field: filterThree", 
exception.getMessage());
+    }
+
+    @Test
+    public void rejectsRepeatedFilterValuesWhenFieldIsNotRepeatable() {
+        IllegalArgumentException exception = 
assertThrows(IllegalArgumentException.class, () ->
+                RestApiUtil.validateFilterParameters(
+                        UtilMisc.toMap("statusId", List.of("PRUN_CREATED", 
"PRUN_RUNNING")),
+                        Set.of("statusId"), null, null));
+
+        assertEquals("Filter parameter does not support repeated values: 
statusId", exception.getMessage());
+    }
+
+    @Test
+    public void preservesRepeatedFilterValuesWhenFieldIsRepeatable() {
+        Map<String, Object> validatedFilters = 
RestApiUtil.validateFilterParameters(
+                UtilMisc.toMap("statusId", List.of("PRUN_CREATED", 
"PRUN_RUNNING")),
+                Set.of("statusId"), Set.of("statusId"), null);
+
+        assertEquals(List.of("PRUN_CREATED", "PRUN_RUNNING"), 
validatedFilters.get("statusId"));
+    }
+
+    @Test
+    public void rejectsInvalidFilterValueFormatWhenValidatorProvided() {
+        IllegalArgumentException exception = 
assertThrows(IllegalArgumentException.class, () ->
+                RestApiUtil.validateFilterParameters(
+                        UtilMisc.toMap("pageSizeHint", "abc"),
+                        Set.of("pageSizeHint"),
+                        null,
+                        UtilMisc.toMap("pageSizeHint", 
(RestApiUtil.FilterValueValidator) (fieldName, value) -> {
+                            if (UtilMisc.toIntegerObject(value) == null) {
+                                throw new IllegalArgumentException("Invalid 
integer value for filter field: " + fieldName);
+                            }
+                        })));
+
+        assertEquals("Invalid integer value for filter field: pageSizeHint", 
exception.getMessage());
+    }
+
+    @Test
+    public void successAddsHttpLinkHeaderWhenPaginationLinksPresent() {
+        Map<String, Object> links = new LinkedHashMap<>();
+        links.put("self", 
RestApiUtil.makeLinkMap("/rest/items?pageIndex=0&pageSize=20", Map.of("rel", 
"self")));
+        links.put("next", 
RestApiUtil.makeLinkMap("/rest/items?pageIndex=1&pageSize=20", Map.of("rel", 
"next")));
+
+        Response response = RestApiUtil.success("Success", Map.of("items", 
List.of(), "links", links));
+
+        assertEquals("</rest/items?pageIndex=0&pageSize=20>; rel=\"self\", "
+                + "</rest/items?pageIndex=1&pageSize=20>; rel=\"next\"", 
response.getHeaderString("Link"));
+    }
+
+    @Test
+    public void successOmitsHttpLinkHeaderWhenPaginationLinksMissing() {
+        Response response = RestApiUtil.success("Success", Map.of("items", 
List.of()));
+
+        assertNull(response.getHeaderString("Link"));
+    }
+}
diff --git 
a/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/util/RestListResponseBuilderTest.java
 
b/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/util/RestListResponseBuilderTest.java
new file mode 100644
index 0000000000..a4f687258a
--- /dev/null
+++ 
b/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/util/RestListResponseBuilderTest.java
@@ -0,0 +1,211 @@
+package org.apache.ofbiz.ws.rs.util;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+
+public final class RestListResponseBuilderTest {
+
+    @Test
+    public void buildsMetadataAndNextLink() {
+        List<Map<String, Object>> productionRuns = 
List.of(Map.of("workEffortId", "PR1001"));
+        Map<String, Object> result = 
RestListResponseBuilder.forList("productionRuns", productionRuns)
+                .pageIndex(0)
+                .pageSize(20)
+                .totalCount(54)
+                .requestPath("/rest/production-runs?pageIndex=0&pageSize=20")
+                .build();
+
+        assertEquals(0, result.get("pageIndex"));
+        assertEquals(20, result.get("pageSize"));
+        assertEquals(54L, result.get("totalCount"));
+        assertEquals(true, result.get("hasNext"));
+        assertEquals(productionRuns, result.get("productionRuns"));
+
+        Map<?, ?> links = (Map<?, ?>) result.get("links");
+        assertNotNull(links.get("next"));
+
+        Map<?, ?> nextLink = (Map<?, ?>) links.get("next");
+        assertEquals("/rest/production-runs?pageIndex=1&pageSize=20", 
nextLink.get("href"));
+        assertEquals("next", nextLink.get("rel"));
+    }
+
+    @Test
+    public void buildsLinksFromPlainRequestPath() {
+        Map<String, Object> result = RestApiUtil.getCollectionResult("items", 
List.of(), 0, 20, 21, "/rest/items");
+
+        Map<?, ?> links = (Map<?, ?>) result.get("links");
+        Map<?, ?> selfLink = (Map<?, ?>) links.get("self");
+        Map<?, ?> nextLink = (Map<?, ?>) links.get("next");
+
+        assertEquals("/rest/items?pageIndex=0&pageSize=20", 
selfLink.get("href"));
+        assertEquals("/rest/items?pageIndex=1&pageSize=20", 
nextLink.get("href"));
+    }
+
+    @Test
+    public void preservesRepeatedQueryParametersInGeneratedLinks() {
+        Map<String, Object> result = RestApiUtil.getCollectionResult("items", 
List.of(), 0, 20, 21,
+                "/rest/items?statusId=A&statusId=B&pageIndex=0&pageSize=20");
+
+        Map<?, ?> links = (Map<?, ?>) result.get("links");
+        Map<?, ?> nextLink = (Map<?, ?>) links.get("next");
+
+        
assertEquals("/rest/items?statusId=A&statusId=B&pageIndex=1&pageSize=20", 
nextLink.get("href"));
+    }
+
+    @Test
+    public void reencodesSpecialCharactersInGeneratedLinks() {
+        Map<String, Object> result = RestApiUtil.getCollectionResult("items", 
List.of(), 0, 20, 21,
+                
"/rest/items?search=red%20shirt&token=a%2Bb%25c%26d%3De&city=%E6%9D%B1%E4%BA%AC&pageIndex=0&pageSize=20");
+
+        Map<?, ?> links = (Map<?, ?>) result.get("links");
+        Map<?, ?> nextLink = (Map<?, ?>) links.get("next");
+        String href = (String) nextLink.get("href");
+
+        assertTrue(href.contains("search=red%20shirt"));
+        assertTrue(href.contains("token=a%2Bb%25c%26d%3De"));
+        assertTrue(href.contains("city=%E6%9D%B1%E4%BA%AC"));
+        assertTrue(href.endsWith("pageIndex=1&pageSize=20"));
+    }
+
+    @Test
+    public void includesTotalPagesAndBoundaryLinks() {
+        Map<String, Object> result = RestListResponseBuilder.forList("items", 
List.of("A", "B"))
+                .pageIndex(1)
+                .pageSize(20)
+                .totalCount(87)
+                .requestPath("/rest/items?pageIndex=1&pageSize=20")
+                .build();
+
+        assertEquals(5L, result.get("totalPages"));
+        assertEquals(1L, result.get("previousPageCount"));
+        assertEquals(3L, result.get("nextPageCount"));
+
+        Map<?, ?> links = (Map<?, ?>) result.get("links");
+        Map<?, ?> firstLink = (Map<?, ?>) links.get("first");
+        Map<?, ?> lastLink = (Map<?, ?>) links.get("last");
+
+        assertEquals("/rest/items?pageIndex=0&pageSize=20", 
firstLink.get("href"));
+        assertEquals("first", firstLink.get("rel"));
+        assertEquals("/rest/items?pageIndex=4&pageSize=20", 
lastLink.get("href"));
+        assertEquals("last", lastLink.get("rel"));
+    }
+
+    @Test
+    public void omitsPrevLinkOnFirstPage() {
+        Map<String, Object> result = RestListResponseBuilder.forList("items", 
List.of("A"))
+                .pageIndex(0)
+                .pageSize(20)
+                .totalCount(40)
+                .requestPath("/rest/items?pageIndex=0&pageSize=20")
+                .build();
+
+        Map<?, ?> links = (Map<?, ?>) result.get("links");
+
+        assertFalse(links.containsKey("prev"));
+        assertTrue(links.containsKey("next"));
+        assertTrue(links.containsKey("first"));
+        assertTrue(links.containsKey("last"));
+    }
+
+    @Test
+    public void omitsBoundaryLinksWhenThereAreNoResults() {
+        Map<String, Object> result = RestListResponseBuilder.forList("items", 
List.of())
+                .pageIndex(0)
+                .pageSize(20)
+                .totalCount(0)
+                .requestPath("/rest/items?pageIndex=0&pageSize=20")
+                .build();
+
+        Map<?, ?> links = (Map<?, ?>) result.get("links");
+
+        assertNotNull(links.get("self"));
+        assertFalse(links.containsKey("first"));
+        assertFalse(links.containsKey("last"));
+        assertFalse(links.containsKey("prev"));
+        assertFalse(links.containsKey("next"));
+    }
+
+    @Test
+    public void 
omitsBoundaryAndNavigationLinksWhenThereAreNoResultsOnLaterPage() {
+        Map<String, Object> result = RestListResponseBuilder.forList("items", 
List.of())
+                .pageIndex(3)
+                .pageSize(20)
+                .totalCount(0)
+                .requestPath("/rest/items?pageIndex=3&pageSize=20")
+                .build();
+
+        Map<?, ?> links = (Map<?, ?>) result.get("links");
+
+        assertNotNull(links.get("self"));
+        assertFalse(links.containsKey("first"));
+        assertFalse(links.containsKey("last"));
+        assertFalse(links.containsKey("prev"));
+        assertFalse(links.containsKey("next"));
+    }
+
+    @Test
+    public void omitsLastLinkWhenLastPageIndexExceedsIntegerMaxValue() {
+        long overflowTotalCount = ((long) Integer.MAX_VALUE + 2L) * 20L;
+        Map<String, Object> result = RestListResponseBuilder.forList("items", 
List.of("A"))
+                .pageIndex(0)
+                .pageSize(20)
+                .totalCount(overflowTotalCount)
+                .requestPath("/rest/items?pageIndex=0&pageSize=20")
+                .build();
+
+        Map<?, ?> links = (Map<?, ?>) result.get("links");
+
+        assertTrue(links.containsKey("first"));
+        assertTrue(links.containsKey("next"));
+        assertFalse(links.containsKey("last"));
+    }
+
+    @Test
+    public void omitsNextLinkWhenNextPageIndexExceedsIntegerMaxValue() {
+        long totalCountWithNextPage = ((long) Integer.MAX_VALUE + 2L) * 20L;
+        Map<String, Object> result = RestListResponseBuilder.forList("items", 
List.of("A"))
+                .pageIndex(Integer.MAX_VALUE)
+                .pageSize(20)
+                .totalCount(totalCountWithNextPage)
+                .requestPath("/rest/items?pageIndex=2147483647&pageSize=20")
+                .build();
+
+        Map<?, ?> links = (Map<?, ?>) result.get("links");
+
+        assertTrue(links.containsKey("self"));
+        assertTrue(links.containsKey("first"));
+        assertTrue(links.containsKey("prev"));
+        assertFalse(links.containsKey("next"));
+    }
+
+    @Test
+    public void omitsLinksWhenRequestPathMissing() {
+        Map<String, Object> result = RestListResponseBuilder.forList("items", 
List.of("A"))
+                .pageIndex(1)
+                .pageSize(20)
+                .totalCount(87)
+                .build();
+
+        assertFalse(result.containsKey("links"));
+    }
+
+    @Test
+    public void includesZeroPreviousAndNextPageCountsWhenNoResults() {
+        Map<String, Object> result = RestListResponseBuilder.forList("items", 
List.of())
+                .pageIndex(0)
+                .pageSize(20)
+                .totalCount(0)
+                .requestPath("/rest/items?pageIndex=0&pageSize=20")
+                .build();
+
+        assertEquals(0L, result.get("previousPageCount"));
+        assertEquals(0L, result.get("nextPageCount"));
+    }
+}
diff --git 
a/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/util/RestQueryOptionsTest.java
 
b/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/util/RestQueryOptionsTest.java
new file mode 100644
index 0000000000..1995c13f2f
--- /dev/null
+++ 
b/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/util/RestQueryOptionsTest.java
@@ -0,0 +1,69 @@
+package org.apache.ofbiz.ws.rs.util;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import org.apache.ofbiz.base.util.UtilMisc;
+import org.junit.jupiter.api.Test;
+
+public final class RestQueryOptionsTest {
+
+    @Test
+    public void normalizesPagingAndCompatibilityAliases() {
+        RestQueryOptions query = 
RestQueryOptions.fromParameters(UtilMisc.toMap(
+                "VIEW_INDEX", "1",
+                "VIEW_SIZE", "25",
+                "sort", "-fieldOne"));
+
+        assertEquals(1, query.getPageIndex());
+        assertEquals(25, query.getPageSize());
+        assertEquals("-fieldOne", query.getSort());
+    }
+
+    @Test
+    public void rejectsInvalidPageSize() {
+        assertThrows(IllegalArgumentException.class, () ->
+                RestQueryOptions.fromParameters(UtilMisc.toMap("pageSize", 
"0")));
+    }
+
+    @Test
+    public void rejectsMalformedPagingValues() {
+        assertThrows(IllegalArgumentException.class, () ->
+                RestQueryOptions.fromParameters(UtilMisc.toMap("pageIndex", 
"abc")));
+        assertThrows(IllegalArgumentException.class, () ->
+                RestQueryOptions.fromParameters(UtilMisc.toMap("pageSize", 
"xyz")));
+    }
+
+    @Test
+    public void usesDefaultPagingWhenMissing() {
+        RestQueryOptions query = 
RestQueryOptions.fromParameters(UtilMisc.toMap("filterOne", "valueOne"));
+
+        assertEquals(RestQueryOptions.DEFAULT_PAGE_INDEX, 
query.getPageIndex());
+        assertEquals(RestQueryOptions.DEFAULT_PAGE_SIZE, query.getPageSize());
+        assertEquals("valueOne", query.getFilters().get("filterOne"));
+    }
+
+    @Test
+    public void preservesFilterParameters() {
+        RestQueryOptions query = 
RestQueryOptions.fromParameters(UtilMisc.toMap(
+                "filterOne", "valueOne",
+                "filterTwo", "valueTwo",
+                "pageIndex", "0",
+                "pageSize", "20"));
+
+        assertEquals("valueOne", query.getFilters().get("filterOne"));
+        assertEquals("valueTwo", query.getFilters().get("filterTwo"));
+    }
+
+    @Test
+    public void normalizesPagingParametersDirectly() {
+        RestQueryOptions query = 
RestQueryOptions.fromParameters(UtilMisc.toMap(
+                "pageIndex", "2",
+                "pageSize", "30",
+                "sort", "fieldOne"));
+
+        assertEquals(2, query.getPageIndex());
+        assertEquals(30, query.getPageSize());
+        assertEquals("fieldOne", query.getSort());
+    }
+}
diff --git a/framework/rest-api/testdef/rest-apiTests.xml 
b/framework/rest-api/testdef/rest-apiTests.xml
index 3cb93145f5..7ef3aa5705 100644
--- a/framework/rest-api/testdef/rest-apiTests.xml
+++ b/framework/rest-api/testdef/rest-apiTests.xml
@@ -21,9 +21,19 @@ under the License.
 <test-suite suite-name="rest-api-tests"
             xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance";
             
xsi:noNamespaceSchemaLocation="https://ofbiz.apache.org/dtds/test-suite.xsd";>
-
     <test-case case-name="rest-api-services-tests">
         <junit-test-suite 
class-name="org.apache.ofbiz.ws.rs.test.RestServicesTests"/>
     </test-case>
-
-</test-suite>
\ No newline at end of file
+    <test-case case-name="rest-collection-query-tests">
+        <junit-test-suite 
class-name="org.apache.ofbiz.ws.rs.util.RestQueryOptionsTest"/>
+    </test-case>
+    <test-case case-name="rest-api-utility-pagination-tests">
+        <junit-test-suite 
class-name="org.apache.ofbiz.ws.rs.util.RestApiUtilPaginationTest"/>
+    </test-case>
+    <test-case case-name="rest-collection-response-tests">
+        <junit-test-suite 
class-name="org.apache.ofbiz.ws.rs.util.RestListResponseBuilderTest"/>
+    </test-case>
+    <test-case case-name="rest-global-exception-mapper-tests">
+        <junit-test-suite 
class-name="org.apache.ofbiz.ws.rs.spi.impl.GlobalExceptionMapperTest"/>
+    </test-case>
+</test-suite>

Reply via email to