I usually post about REST APIs and HTTP on Twitter - you can follow me there:
Follow @baeldungOverview
In this fifth article of the series we’ll illustrate building the REST API Query language with the help of a cool library – rsql-parser.
RSQL is a super-set of the Feed Item Query Language (FIQL) – a clean and simple filter syntax for feeds; so it fits quite naturally into a REST API.
1. Preparations
First, let’s add a maven dependency to the library:
<dependency> <groupId>cz.jirutka.rsql</groupId> <artifactId>rsql-parser</artifactId> <version>2.0.0</version> </dependency>
And also define the main entity we’re going to be working with throughout the examples – User:
@Entity public class User { @Id @GeneratedValue(strategy = GenerationType.AUTO) private Long id; private String firstName; private String lastName; private String email; private int age; }
2. Parse the Request to
The way RSQL expressions are represented internally is in the form of nodes and the visitor pattern is used parse out the input.
With that in mind, we’re going to implement the RSQLVisitor interface and create our own visitor implementation – CustomRsqlVisitor:
public class CustomRsqlVisitor<T> implements RSQLVisitor<Specification<User>, Void> { private UserRsqlSpecBuilder builder; public CustomRsqlVisitor() { builder = new UserRsqlSpecBuilder(); } @Override public Specification<User> visit(AndNode node, Void param) { return builder.createSpecification(node); } @Override public Specification<User> visit(OrNode node, Void param) { return builder.createSpecification(node); } @Override public Specification<User> visit(ComparisonNode node, Void params) { return builder.createSpecification(node); } }
Now we need to deal with persistence and construct our query out of each of these nodes.
We’re going to use the Spring Data JPA Specifications we used before – and we’re going to implement a Specification builder to construct Specifications out of each of these nodes we visit:
public class UserRsqlSpecBuilder { public Specifications<User> createSpecification(Node node) { if (node instanceof LogicalNode) { return createSpecification((LogicalNode) node); } if (node instanceof ComparisonNode) { return createSpecification((ComparisonNode) node); } return null; } public Specifications<User> createSpecification(LogicalNode logicalNode) { List<Specifications<User>> specs = new ArrayList<Specifications<User>>(); Specifications<User> temp; for (Node node : logicalNode.getChildren()) { temp = createSpecification(node); if (temp != null) { specs.add(temp); } } Specifications<User> result = specs.get(0); if (logicalNode.getOperator() == LogicalOperator.AND) { for (int i = 1; i < specs.size(); i++) { result = Specifications.where(result).and(specs.get(i)); } } else if (logicalNode.getOperator() == LogicalOperator.OR) { for (int i = 1; i < specs.size(); i++) { result = Specifications.where(result).or(specs.get(i)); } } return result; } public Specifications<User> createSpecification(ComparisonNode comparisonNode) { Specifications<User> result = Specifications.where( new UserRsqlSpecification( comparisonNode.getSelector(), comparisonNode.getOperator(), comparisonNode.getArguments() ) ); return result; } }
Note how:
- LogicalNode is an AND/OR Node and has multiple children
- ComparisonNode has no children and it hold the Selector, Operator and the Arguments
For example, for a query “name==john” – we have:
- Selector: “name”
- Operator: “==”
- Arguments:[john]
3. Create Custom Specification
When constructing the query we made use of a custom User Specification – “UserRsqlSpecification“:
public class UserRsqlSpecification implements Specification<User> { private String property; private ComparisonOperator operator; private List<String> arguments; public UserRsqlSpecification( String property, ComparisonOperator operator, List<String> arguments) { super(); this.property = property; this.operator = operator; this.arguments = arguments; } @Override public Predicate toPredicate( Root<User> root, CriteriaQuery<?> query, CriteriaBuilder builder) { List<Object> args = castArguments(root); Object argument = args.get(0); switch (RsqlSearchOperation.getSimpleOperator(operator)) { case EQUAL: { if (argument instanceof String) { return builder.like( root.<String> get(property), argument.toString().replace('*', '%')); } else if (argument == null) { return builder.isNull(root.get(property)); } else { return builder.equal(root.get(property), argument); } } case NOT_EQUAL: { if (argument instanceof String) { return builder.notLike( root.<String> get(property), argument.toString().replace('*', '%')); } else if (argument == null) { return builder.isNotNull(root.get(property)); } else { return builder.notEqual(root.get(property), argument); } } case GREATER_THAN: { return builder.greaterThan(root.<String> get(property), argument.toString()); } case GREATER_THAN_OR_EQUAL: { return builder.greaterThanOrEqualTo( root.<String> get(property), argument.toString()); } case LESS_THAN: { return builder.lessThan(root.<String> get(property), argument.toString()); } case LESS_THAN_OR_EQUAL: { return builder.lessThanOrEqualTo( root.<String> get(property), argument.toString()); } case IN: return root.get(property).in(args); case NOT_IN: return builder.not(root.get(property).in(args)); } return null; } private List<Object> castArguments(Root<User> root) { List<Object> args = new ArrayList<Object>(); Class<? extends Object> type = root.get(property).getJavaType(); for (String argument : arguments) { if (type.equals(Integer.class)) { args.add(Integer.parseInt(argument)); } else if (type.equals(Long.class)) { args.add(Long.parseLong(argument)); } else { args.add(argument); } } return args; } }
And here is our enum “RsqlSearchOperation“ which holds default rsql-parser operators:
public enum RsqlSearchOperation { EQUAL(RSQLOperators.EQUAL), NOT_EQUAL(RSQLOperators.NOT_EQUAL), GREATER_THAN(RSQLOperators.GREATER_THAN), GREATER_THAN_OR_EQUAL(RSQLOperators.GREATER_THAN_OR_EQUAL), LESS_THAN(RSQLOperators.LESS_THAN), LESS_THAN_OR_EQUAL(RSQLOperators.LESS_THAN_OR_EQUAL), IN(RSQLOperators.IN), NOT_IN(RSQLOperators.NOT_IN); private ComparisonOperator operator; private RsqlSearchOperation(ComparisonOperator operator) { this.operator = operator; } public static RsqlSearchOperation getSimpleOperator(ComparisonOperator operator) { for (RsqlSearchOperation operation : values()) { if (operation.getOperator() == operator) { return operation; } } return null; } }
4. Test Search Queries
Let’s now start testing our new and flexible operations through some real-world scenarios:
First – let’s initialize the data:
@RunWith(SpringJUnit4ClassRunner.class) @ContextConfiguration(classes = { PersistenceConfig.class }) @Transactional @TransactionConfiguration public class RsqlTest { @Autowired private UserRepository repository; private User userJohn; private User userTom; @Before public void init() { userJohn = new User(); userJohn.setFirstName("john"); userJohn.setLastName("doe"); userJohn.setEmail("john@doe.com"); userJohn.setAge(22); repository.save(userJohn); userTom = new User(); userTom.setFirstName("tom"); userTom.setLastName("doe"); userTom.setEmail("tom@doe.com"); userTom.setAge(26); repository.save(userTom); } }
Now let’s test the different operations:
4.1. Test Equality
In the following example – we’ll search for users by their first and last name:
@Test public void givenFirstAndLastName_whenGettingListOfUsers_thenCorrect() { Node rootNode = new RSQLParser().parse("firstName==john;lastName==doe"); Specification<User> spec = rootNode.accept(new CustomRsqlVisitor<User>()); List<User> results = repository.findAll(spec); assertThat(userJohn, isIn(results)); assertThat(userTom, not(isIn(results))); }
4.2. Test Negation
Next, let’s search for users that by the their first name not “john”:
@Test public void givenFirstNameInverse_whenGettingListOfUsers_thenCorrect() { Node rootNode = new RSQLParser().parse("firstName!=john"); Specification<User> spec = rootNode.accept(new CustomRsqlVisitor<User>()); List<User> results = repository.findAll(spec); assertThat(userTom, isIn(results)); assertThat(userJohn, not(isIn(results))); }
4.3. Test Greater Than
Next – we will search for users with age greater than “25”:
@Test public void givenMinAge_whenGettingListOfUsers_thenCorrect() { Node rootNode = new RSQLParser().parse("age>25"); Specification<User> spec = rootNode.accept(new CustomRsqlVisitor<User>()); List<User> results = repository.findAll(spec); assertThat(userTom, isIn(results)); assertThat(userJohn, not(isIn(results))); }
4.4. Test Like
Next – we will search for users with their first name starting with “jo”:
@Test public void givenFirstNamePrefix_whenGettingListOfUsers_thenCorrect() { Node rootNode = new RSQLParser().parse("firstName==jo*"); Specification<User> spec = rootNode.accept(new CustomRsqlVisitor<User>()); List<User> results = repository.findAll(spec); assertThat(userJohn, isIn(results)); assertThat(userTom, not(isIn(results))); }
4.5. Test IN
Next – we will search for users their first name is “john” or “jack“:
@Test public void givenListOfFirstName_whenGettingListOfUsers_thenCorrect() { Node rootNode = new RSQLParser().parse("firstName=in=(john,jack)"); Specification<User> spec = rootNode.accept(new CustomRsqlVisitor<User>()); List<User> results = repository.findAll(spec); assertThat(userJohn, isIn(results)); assertThat(userTom, not(isIn(results))); }
5. UserController
Finally – let’s tie it all in with the controller:
@RequestMapping(method = RequestMethod.GET, value = "/users") @ResponseBody public List<User> findAllByRsql(@RequestParam(value = "search") String search) { Node rootNode = new RSQLParser().parse(search); Specification<User> spec = rootNode.accept(new CustomRsqlVisitor<User>()); return dao.findAll(spec); }
Here’s a sample URL:
http://localhost:8080/users?search=firstName==jo*;age<25
And the response:
[{ "id":1, "firstName":"john", "lastName":"doe", "email":"john@doe.com", "age":24 }]
6. Conclusion
This tutorial illustrated how to build out a Query/Search Language for a REST API without having to re-invent the syntax and instead using FIQL / RSQL.
The full implementation of this article can be found in the github project – this is an Eclipse based project, so it should be easy to import and run as it is.