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>