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> productionRuns = List.of(Map.of("workEffortId", "PR1001")); + Map 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 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 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 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 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")); + + 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 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 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 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 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 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 result = RestListResponseBuilder.forList("items", List.of("A")) + .pageIndex(1) + .pageSize(20) + .totalCount(87) + .build(); + + assertFalse(result.containsKey("links")); + } +} 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 00000000000..1995c13f2fd --- /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 3cb93145f50..7ef3aa57053 100644 --- a/framework/rest-api/testdef/rest-apiTests.xml +++ b/framework/rest-api/testdef/rest-apiTests.xml @@ -21,9 +21,19 @@ under the License. - - - \ No newline at end of file + + + + + + + + + + + + +