Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 26 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,16 +223,37 @@ api.pagination.max-page-size=500

## Error Handling

The library throws a `QueryException` when security or syntax rules are violated:
The library provides a hierarchy of exceptions to distinguish between client-side validation errors and developer-side configuration issues. All exceptions extend the base `QueryException`.

- Filtering on a non-`@RsqlFilterable` field.
- Using a restricted operator on a field.
- Sorting on a non-`@Sortable` field.
- Invalid RSQL syntax.
### Exception Hierarchy

- **`QueryValidationException`**: Thrown when an API consumer provides invalid input. These should typically be returned as a `400 Bad Request`.
- Filtering on a non-`@RsqlFilterable` field.
- Using a disallowed operator on a field.
- Sorting on a non-`@Sortable` field.
- Using an original field name when a mapping alias is required (`allowOriginalFieldName = false`).
- Malformed RSQL syntax.
- **`QueryConfigurationException`**: Thrown when the library or entity mapping is misconfigured by the developer. These should typically be treated as a `500 Internal Server Error`.
- Custom operators referenced in `@RsqlFilterable` that are not registered.
- Field mappings pointing to non-existent fields on the entity.

### Handling Exceptions

```java
@ControllerAdvice
public class GlobalExceptionHandler {

@ExceptionHandler(QueryValidationException.class)
public ResponseEntity<String> handleValidationException(QueryValidationException ex) {
return ResponseEntity.badRequest().body(ex.getMessage());
}

@ExceptionHandler(QueryConfigurationException.class)
public ResponseEntity<String> handleConfigurationException(QueryConfigurationException ex) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Internal configuration error");
}

// Alternatively, catch the base exception for unified handling
@ExceptionHandler(QueryException.class)
public ResponseEntity<String> handleQueryException(QueryException ex) {
return ResponseEntity.badRequest().body(ex.getMessage());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import in.co.akshitbansal.springwebquery.annotation.RestrictedPageable;
import in.co.akshitbansal.springwebquery.annotation.Sortable;
import in.co.akshitbansal.springwebquery.exception.QueryException;
import in.co.akshitbansal.springwebquery.exception.QueryValidationException;
import in.co.akshitbansal.springwebquery.util.ReflectionUtil;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
Expand Down Expand Up @@ -36,7 +36,7 @@
* on fields explicitly annotated with {@link Sortable}.
* <p>
* If a requested sort field is not annotated as {@link Sortable}, a
* {@link QueryException} is thrown.
* {@link QueryValidationException} is thrown.
*/
@RequiredArgsConstructor
public class RestrictedPageableArgumentResolver implements HandlerMethodArgumentResolver {
Expand Down Expand Up @@ -73,15 +73,15 @@ public boolean supportsParameter(MethodParameter parameter) {
* <li>Delegate parsing of page, size, and sort parameters to {@link #delegate}.</li>
* <li>Retrieve the target entity class from the {@link RestrictedPageable} annotation.</li>
* <li>Validate each requested {@link Sort.Order} against the entity's sortable fields.
* If a field is not annotated with {@link Sortable}, a {@link QueryException} is thrown.</li>
* If a field is not annotated with {@link Sortable}, a {@link QueryValidationException} is thrown.</li>
* </ol>
*
* @param methodParameter the method parameter for which the value should be resolved
* @param mavContainer the ModelAndViewContainer (can be {@code null})
* @param webRequest the current request
* @param binderFactory a factory for creating WebDataBinder instances (can be {@code null})
* @return a {@link Pageable} object containing page, size, and validated sort information
* @throws QueryException if any requested sort field is not marked as {@link Sortable}
* @throws QueryValidationException if any requested sort field is not marked as {@link Sortable}
*/
@Override
public Pageable resolveArgument(
Expand All @@ -101,10 +101,18 @@ public Pageable resolveArgument(
for(Sort.Order order : pageable.getSort()) {
String fieldName = order.getProperty();
// Resolve the field on the entity (including inherited fields)
Field field = ReflectionUtil.resolveField(entityClass, fieldName);
Field field;
try {
field = ReflectionUtil.resolveField(entityClass, fieldName);
}
catch (Exception ex) {
throw new QueryValidationException(MessageFormat.format(
"Unknown field ''{0}''", fieldName
), ex);
}
// Reject sorting on fields not explicitly marked as sortable
if(!field.isAnnotationPresent(Sortable.class))
throw new QueryException(MessageFormat.format("Sorting is not allowed on the field ''{0}''", fieldName));
throw new QueryValidationException(MessageFormat.format("Sorting is not allowed on the field ''{0}''", fieldName));
}

return pageable;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import in.co.akshitbansal.springwebquery.annotation.RsqlFilterable;
import in.co.akshitbansal.springwebquery.annotation.RsqlSpec;
import in.co.akshitbansal.springwebquery.exception.QueryException;
import in.co.akshitbansal.springwebquery.exception.QueryValidationException;
import in.co.akshitbansal.springwebquery.operator.RsqlCustomOperator;
import in.co.akshitbansal.springwebquery.operator.RsqlOperator;
import io.github.perplexhub.rsql.QuerySupport;
Expand Down Expand Up @@ -144,7 +145,7 @@ public boolean supportsParameter(MethodParameter parameter) {
* @param binderFactory the data binder factory
* @return a {@link Specification} representing the RSQL query,
* or an unrestricted Specification if the query is absent
* @throws QueryException if the RSQL query is invalid or violates
* @throws QueryValidationException if the RSQL query is invalid or violates
* {@link RsqlFilterable} constraints
*/
@Override
Expand Down Expand Up @@ -192,10 +193,7 @@ public Specification<?> resolveArgument(
return RSQLJPASupport.toSpecification(querySupport);
}
catch (RSQLParserException ex) {
throw new QueryException("Unable to parse rsql query param", ex);
}
catch (QueryException ex) {
throw ex;
throw new QueryValidationException("Unable to parse rsql query param", ex);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
import cz.jirutka.rsql.parser.ast.*;
import in.co.akshitbansal.springwebquery.annotation.FieldMapping;
import in.co.akshitbansal.springwebquery.annotation.RsqlFilterable;
import in.co.akshitbansal.springwebquery.exception.QueryConfigurationException;
import in.co.akshitbansal.springwebquery.exception.QueryException;
import in.co.akshitbansal.springwebquery.exception.QueryValidationException;
import in.co.akshitbansal.springwebquery.operator.RsqlCustomOperator;
import in.co.akshitbansal.springwebquery.operator.RsqlOperator;
import in.co.akshitbansal.springwebquery.util.ReflectionUtil;
Expand Down Expand Up @@ -123,8 +125,9 @@ public Void visit(OrNode orNode, Void unused) {
* @param comparisonNode the comparison node
* @param unused unused parameter
* @return null
* @throws QueryException if the field does not exist, is not filterable,
* @throws QueryValidationException if the field does not exist, is not filterable,
* or the operator is not allowed
* @throws QueryConfigurationException if a custom operator or field mapping is misconfigured
*/
@Override
public Void visit(ComparisonNode comparisonNode, Void unused) {
Expand All @@ -136,7 +139,8 @@ public Void visit(ComparisonNode comparisonNode, Void unused) {
* Validates a comparison node against the entity class.
*
* @param node the comparison node to validate
* @throws QueryException if the field is not allowed or operator is invalid
* @throws QueryValidationException if the field is not allowed or operator is invalid
* @throws QueryConfigurationException if the field mapping is misconfigured
*/
private void validate(ComparisonNode node) {
// Extract the field name and operator from the RSQL node
Expand All @@ -146,7 +150,7 @@ private void validate(ComparisonNode node) {
// Find if there exists a field mapping with original field name and throw error if use is not allowed
FieldMapping originalFieldMapping = originalFieldMappings.get(fieldName);
if(originalFieldMapping != null && !originalFieldMapping.allowOriginalFieldName())
throw new QueryException(MessageFormat.format(
throw new QueryValidationException(MessageFormat.format(
"Unknown field ''{0}''", fieldName
));

Expand All @@ -156,13 +160,21 @@ private void validate(ComparisonNode node) {
if(fieldMapping != null) fieldName = fieldMapping.field();

// Resolve the Field object from the entity class using reflection
Field field = ReflectionUtil.resolveField(entityClass, fieldName);
Field field;
try {
field = ReflectionUtil.resolveField(entityClass, fieldName);
}
catch (Exception ex) {
throw new QueryValidationException(MessageFormat.format(
"Unknown field ''{0}''", fieldName
), ex);
}

// Retrieve the RsqlFilterable annotation on the field (if present)
RsqlFilterable filterable = field.getAnnotation(RsqlFilterable.class);

// Throw exception if the field is not annotated as filterable
if(filterable == null) throw new QueryException(MessageFormat.format(
if(filterable == null) throw new QueryValidationException(MessageFormat.format(
"Filtering not allowed on field ''{0}''", reqFieldName
));

Expand All @@ -185,7 +197,7 @@ private void validate(ComparisonNode node) {
.collect(Collectors.toSet());

// Throw exception if the provided operator is not in the allowed set
if(!allowedOperators.contains(operator)) throw new QueryException(MessageFormat.format(
if(!allowedOperators.contains(operator)) throw new QueryValidationException(MessageFormat.format(
"Operator ''{0}'' not allowed on field ''{1}''", operator, reqFieldName
));
}
Expand All @@ -195,11 +207,11 @@ private void validate(ComparisonNode node) {
*
* @param clazz the custom operator class to look up
* @return the registered custom operator instance
* @throws QueryException if the custom operator class is not registered
* @throws QueryConfigurationException if the custom operator class is not registered
*/
private RsqlCustomOperator<?> getCustomOperator(Class<?> clazz) {
RsqlCustomOperator<?> operator = customOperators.get(clazz);
if(operator == null) throw new QueryException(MessageFormat.format(
if(operator == null) throw new QueryConfigurationException(MessageFormat.format(
"Custom operator ''{0}'' referenced in @RsqlFilterable is not registered", clazz.getSimpleName()
));
return operator;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package in.co.akshitbansal.springwebquery.exception;

/**
* Exception thrown when the library is misconfigured by the developer.
* <p>
* This exception indicates an internal configuration error, such as referencing
* a custom operator that has not been registered with the {@code RsqlSpecificationArgumentResolver}.
* </p>
*
* <p>This exception is intended to be treated as a 5xx server error
* as it highlights a development-time configuration issue.</p>
*/
public class QueryConfigurationException extends QueryException {

/**
* Constructs a new query configuration exception with the specified detail message.
*
* @param message the detail message explaining the reason for the configuration error
*/
public QueryConfigurationException(String message) {
super(message);
}

/**
* Constructs a new query configuration exception with the specified detail message and cause.
*
* @param message the detail message explaining the reason for the configuration error
* @param cause the underlying cause of the configuration error
*/
public QueryConfigurationException(String message, Throwable cause) {
super(message, cause);
}
}
Original file line number Diff line number Diff line change
@@ -1,30 +1,28 @@
package in.co.akshitbansal.springwebquery.exception;

import in.co.akshitbansal.springwebquery.annotation.FieldMapping;
import in.co.akshitbansal.springwebquery.annotation.RsqlFilterable;
import in.co.akshitbansal.springwebquery.annotation.Sortable;

/**
* Exception thrown when an RSQL query or pagination request violates
* configured field or operator restrictions.
* Base exception thrown for all RSQL query or pagination related errors.
* <p>
* This exception is typically thrown in the following scenarios:
* This class serves as the parent for more specific exceptions that distinguish
* between client-side validation errors and developer-side configuration errors:
* </p>
* <ul>
* <li>A query attempts to filter on a field not annotated with
* {@link RsqlFilterable}</li>
* <li>A query uses a default or custom operator not allowed for a specific field</li>
* <li>A query uses original field name when the behavior is disabled via {@link FieldMapping#allowOriginalFieldName()}</li>
* <li>A sort request targets a field not annotated with
* {@link Sortable}</li>
* <li>A field referenced in a query does not exist on the entity</li>
* <li>A custom operator referenced in {@link RsqlFilterable} is not registered</li>
* <li>The RSQL query syntax is malformed or cannot be parsed</li>
* <li>{@link QueryValidationException}: Thrown when an API consumer provides
* an invalid query or violates validation rules (e.g., malformed RSQL,
* disallowed operators, non-filterable fields).</li>
* <li>{@link QueryConfigurationException}: Thrown when the library or
* entity mapping is misconfigured by the developer (e.g., unregistered
* custom operators).</li>
* </ul>
*
* <p>This is a runtime exception and is intended to be caught and handled
* at the controller or advice layer to provide meaningful error responses
* to API clients.</p>
* <p>Using this base exception in a controller advice or catch block allows
* handling all query-related errors in a unified manner.</p>
*
* @see QueryValidationException
* @see QueryConfigurationException
* @see RsqlFilterable
* @see Sortable
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package in.co.akshitbansal.springwebquery.exception;

/**
* Exception thrown when an API consumer provides an invalid RSQL query,
* sorting request, or filter parameters.
* <p>
* This exception indicates that the request itself is malformed or violates
* configured validation rules (e.g., filtering on a non-filterable field,
* using a disallowed operator, or providing invalid RSQL syntax).
* </p>
*
* <p>This exception is intended to be caught and returned as a 4xx client
* error to the consumer.</p>
*/
public class QueryValidationException extends QueryException {

/**
* Constructs a new query validation exception with the specified detail message.
*
* @param message the detail message explaining the reason for the validation failure
*/
public QueryValidationException(String message) {
super(message);
}

/**
* Constructs a new query validation exception with the specified detail message and cause.
*
* @param message the detail message explaining the reason for the validation failure
* @param cause the underlying cause of the validation failure (e.g., {@link cz.jirutka.rsql.parser.RSQLParserException})
*/
public QueryValidationException(String message, Throwable cause) {
super(message, cause);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,8 @@ private static Field resolveFieldUpHierarchy(Class<?> type, String name) {
current = current.getSuperclass();
}
}
throw new QueryException(MessageFormat.format(
"Unknown field ''{0}''", name
throw new RuntimeException(MessageFormat.format(
"Field ''{0}'' not found in class hierarchy of {1}", name, type
));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import in.co.akshitbansal.springwebquery.annotation.RestrictedPageable;
import in.co.akshitbansal.springwebquery.annotation.Sortable;
import in.co.akshitbansal.springwebquery.exception.QueryException;
import in.co.akshitbansal.springwebquery.exception.QueryValidationException;
import org.junit.jupiter.api.Test;
import org.springframework.core.MethodParameter;
import org.springframework.data.domain.Pageable;
Expand All @@ -13,10 +13,7 @@

import java.lang.reflect.Method;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.*;

class RestrictedPageableArgumentResolverTest {

Expand Down Expand Up @@ -55,7 +52,7 @@ void resolveArgument_rejectsNonSortableField() throws NoSuchMethodException {
MethodParameter parameter = new MethodParameter(method, 0);
NativeWebRequest request = requestWithSort("secret,desc");

assertThrows(QueryException.class, () -> resolver.resolveArgument(parameter, null, request, null));
assertThrows(QueryValidationException.class, () -> resolver.resolveArgument(parameter, null, request, null));
}

private static NativeWebRequest requestWithSort(String sort) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import in.co.akshitbansal.springwebquery.annotation.FieldMapping;
import in.co.akshitbansal.springwebquery.annotation.RsqlFilterable;
import in.co.akshitbansal.springwebquery.annotation.RsqlSpec;
import in.co.akshitbansal.springwebquery.exception.QueryException;
import in.co.akshitbansal.springwebquery.exception.QueryValidationException;
import in.co.akshitbansal.springwebquery.operator.RsqlCustomOperator;
import in.co.akshitbansal.springwebquery.operator.RsqlOperator;
import io.github.perplexhub.rsql.RSQLCustomPredicateInput;
Expand Down Expand Up @@ -59,7 +59,7 @@ void resolveArgument_rejectsInvalidRsqlSyntax() throws NoSuchMethodException {
MethodParameter parameter = new MethodParameter(method, 0);
NativeWebRequest webRequest = requestWithFilter("name==");

assertThrows(QueryException.class, () -> resolver.resolveArgument(parameter, null, webRequest, null));
assertThrows(QueryValidationException.class, () -> resolver.resolveArgument(parameter, null, webRequest, null));
}

@Test
Expand All @@ -77,7 +77,7 @@ void resolveArgument_rejectsOriginalMappedFieldWhenNotAllowed() throws NoSuchMet
MethodParameter parameter = new MethodParameter(method, 0);
NativeWebRequest webRequest = requestWithFilter("name==john");

assertThrows(QueryException.class, () -> resolver.resolveArgument(parameter, null, webRequest, null));
assertThrows(QueryValidationException.class, () -> resolver.resolveArgument(parameter, null, webRequest, null));
}

@Test
Expand Down
Loading