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 3423ae359ac..ab6c9cb843f 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}
*
*
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.
+ * {@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.
*/
@Override
public Response toResponse(Throwable throwable) {
@@ -44,9 +46,14 @@ public Response toResponse(Throwable throwable) {
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 ef9637b68a5..2bb33871cd0 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 @@
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 @@ private 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 static String getErrorMessage(String serviceName, String errorKey, Locale
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 parameters, String... aliases) {
+ if (UtilValidate.isEmpty(parameters) || aliases == null || aliases.length == 0) {
+ return null;
+ }
+ for (String alias : aliases) {
+ for (Map.Entry 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 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 extractQueryParameters(String requestPath) {
+ List 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 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 makeLinkMap(String href, Map params) {
+ Map 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 validateSortFields(String sortExpression, Set allowedFields) {
+ if (UtilValidate.isEmpty(sortExpression)) {
+ return Collections.emptyList();
+ }
+
+ List validatedFields = new ArrayList<>();
+ Set 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 validateFilterParameters(Map filters, Set 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 validateFilterParameters(Map filters, Set allowedFields,
+ Set repeatableFields, Map valueValidators) {
+ if (UtilValidate.isEmpty(filters)) {
+ return Collections.emptyMap();
+ }
+
+ Map validatedFilters = new LinkedHashMap<>();
+ for (Map.Entry 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 links) {
+ if (UtilValidate.isEmpty(links)) {
+ return null;
+ }
+
+ List headerParts = new ArrayList<>();
+ for (Map.Entry entry : links.entrySet()) {
+ Map 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) 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 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 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 castLinkMap(Object value) {
+ return value instanceof Map, ?> ? (Map) value : Collections.emptyMap();
+ }
+
+ private static void validateFilterValues(String fieldName, Object value, Set repeatableFields,
+ Map 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 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 00000000000..b7604ec31ef
--- /dev/null
+++ b/framework/rest-api/src/main/java/org/apache/ofbiz/ws/rs/util/RestListResponseBuilder.java
@@ -0,0 +1,212 @@
+/*******************************************************************************
+ * 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 build() {
+ Map 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(collectionName, collectionData);
+
+ Map 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 Map buildLinks() {
+ Map 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 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 00000000000..c12755cd89a
--- /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 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 filters;
+
+ private RestQueryOptions(int pageIndex, int pageSize, String sort, Map 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 parameters) {
+ Map filters = new LinkedHashMap<>();
+ if (UtilValidate.isNotEmpty(parameters)) {
+ for (Map.Entry 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 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 00000000000..423ba68e300
--- /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 00000000000..cf2c676270d
--- /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 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 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 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("; rel=\"first\", "
+ + "; rel=\"next\"", header);
+ }
+
+ @Test
+ public void omitsIncompleteRelationsFromHttpLinkHeader() {
+ Map 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("; rel=\"self\"", header);
+ }
+
+ @Test
+ public void returnsNullLinkHeaderWhenNoRelationsAvailable() {
+ assertNull(RestApiUtil.toLinkHeaderValue(null));
+ assertNull(RestApiUtil.toLinkHeaderValue(Map.of()));
+ }
+
+ @Test
+ public void validatesAllowedFilterFields() {
+ Map 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 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 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 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("; rel=\"self\", "
+ + "; 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 00000000000..424278eeed9
--- /dev/null
+++ b/framework/rest-api/src/test/java/org/apache/ofbiz/ws/rs/util/RestListResponseBuilderTest.java
@@ -0,0 +1,196 @@
+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