I usually post about REST APIs and HTTP on Google+ - you can follow me there:
1. Overview
In this tutorial – we will build a Search/Filter REST API using Spring Data JPA and Specifications.
We started looking at a query language in the first article of this series – with a JPA Criteria based solution.
So – why a query language? Because – for any complex enough API – searching/filtering your resources by very simple fields is simply not enough. A query language is more flexible, and allows you to filter down to exactly the resources you need.
2. User Entity
First – let’s start with a simple User entity for our Search API:
@Entity public class User { @Id @GeneratedValue(strategy = GenerationType.AUTO) private Long id; private String firstName; private String lastName; private String email; private int age; // standard getters and setters }
3. Filter using Specifications
Now – let’s get straight into the most interesting part of the problem – querying with custom Spring Data JPA Specifications.
We’ll create a UserSpecification which implements the Specification interface and we’re going to pass in our own constraint to construct the actual query:
public class UserSpecification implements Specification<User> { private SearchCriteria criteria; @Override public Predicate toPredicate (Root<User> root, CriteriaQuery<?> query, CriteriaBuilder builder) { if (criteria.getOperation().equalsIgnoreCase(">")) { return builder.greaterThanOrEqualTo( root.<String> get(criteria.getKey()), criteria.getValue().toString()); } else if (criteria.getOperation().equalsIgnoreCase("<")) { return builder.lessThanOrEqualTo( root.<String> get(criteria.getKey()), criteria.getValue().toString()); } else if (criteria.getOperation().equalsIgnoreCase(":")) { if (root.get(criteria.getKey()).getJavaType() == String.class) { return builder.like( root.<String>get(criteria.getKey()), "%" + criteria.getValue() + "%"); } else { return builder.equal(root.get(criteria.getKey()), criteria.getValue()); } } return null; } }
As we can see – we create a Specification based on some simple constrains which we represent in the following “SearchCriteria” class:
public class SearchCriteria { private String key; private String operation; private Object value; }
The SearchCriteria implementation holds a basic representation of a constraint – and it’s based on this constraint that we’re going to be constructing the query:
- key: the field name – for example: firstName, age, … etc.
- operation: the operation – for example: equality, less than, … etc.
- value: the field value – for example: john, 25, … etc.
Of course the implementation is simplistic and can be improved; it is however a solid base for the powerful and flexible operations we need.
4. The UserRepository
Next – let’s take a look at the UserRepository; we’re simply extending the JpaSpecificationExecutor to get the new Specification APIs:
public interface UserRepository extends JpaRepository<User, Long>, JpaSpecificationExecutor<User> {}
5. Test the Search Queries
Now – let’s test out the new search API.
First, let’s create a few users to have them ready when the tests runs:
@RunWith(SpringJUnit4ClassRunner.class) @ContextConfiguration(classes = { PersistenceJPAConfig.class }) @Transactional @TransactionConfiguration public class JPASpecificationsTest { @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); } }
Next, let’s see how to find users with given last name:
@Test public void givenLast_whenGettingListOfUsers_thenCorrect() { UserSpecification spec = new UserSpecification(new SearchCriteria("lastName", ":", "doe")); List<User> results = repository.findAll(spec); assertThat(userJohn, isIn(results)); assertThat(userTom, isIn(results)); }
Now, let’s see how to find user with given both first and last name:
@Test public void givenFirstAndLastName_whenGettingListOfUsers_thenCorrect() { UserSpecification spec1 = new UserSpecification(new SearchCriteria("firstName", ":", "john")); UserSpecification spec2 = new UserSpecification(new SearchCriteria("lastName", ":", "doe")); List<User> results = repository.findAll(Specifications.where(spec1).and(spec2)); assertThat(userJohn, isIn(results)); assertThat(userTom, not(isIn(results))); }
Note: We used “where” and “and” to combine Specifications.
Next, let’s see how to find user with given both last name and minimum age:
@Test public void givenLastAndAge_whenGettingListOfUsers_thenCorrect() { UserSpecification spec1 = new UserSpecification(new SearchCriteria("age", ">", "25")); UserSpecification spec2 = new UserSpecification(new SearchCriteria("lastName", ":", "doe")); List<User> results = repository.findAll(Specifications.where(spec1).and(spec2)); assertThat(userTom, isIn(results)); assertThat(userJohn, not(isIn(results))); }
Now, let’s see how to search for User that doesn’t actually exist:
@Test public void givenWrongFirstAndLast_whenGettingListOfUsers_thenCorrect() { UserSpecification spec1 = new UserSpecification(new SearchCriteria("firstName", ":", "Adam")); UserSpecification spec2 = new UserSpecification(new SearchCriteria("lastName", ":", "Fox")); List<User> results = repository.findAll(Specifications.where(spec1).and(spec2)); assertThat(userJohn, not(isIn(results))); assertThat(userTom, not(isIn(results))); }
Finally – let’s see how to find a User given only part of the first name:
@Test public void givenPartialFirst_whenGettingListOfUsers_thenCorrect() { UserSpecification spec = new UserSpecification(new SearchCriteria("firstName", ":", "jo")); List<User> results = repository.findAll(spec); assertThat(userJohn, isIn(results)); assertThat(userTom, not(isIn(results))); }
6. Combine Specifications
Next – let’s take a look at combining our custom Specifications to use multiple constraints and filter according to multiple criteria.
We’re going to implement a builder – UserSpecificationsBuilder – to easily and fluently combine Specifications:
public class UserSpecificationsBuilder { private final List<SearchCriteria> params; public UserSpecificationsBuilder() { params = new ArrayList<SearchCriteria>(); } public UserSpecificationsBuilder with(String key, String operation, Object value) { params.add(new SearchCriteria(key, operation, value)); return builder; } public Specification<User> build() { if (params.size() == 0) { return null; } List<Specification<User>> specs = new ArrayList<Specification<User>>(); for (SearchCriteria param : params) { specs.add(new UserSpecification(param)); } Specification<User> result = specs.get(0); for (int i = 1; i < specs.size(); i++) { result = Specifications.where(result).and(specs.get(i)); } return result; } }
7. UserController
Finally – let’s use this new persistence search/filter functionality and set up the REST API – by creating a UserController with a simple search operation:
@Controller public class UserController { @Autowired private UserRepository repo; @RequestMapping(method = RequestMethod.GET, value = "/users") @ResponseBody public List<User> search(@RequestParam(value = "search") String search) { UserSpecificationsBuilder builder = new UserSpecificationsBuilder(); Pattern pattern = Pattern.compile("(\\w+?)(:|<|>)(\\w+?),"); Matcher matcher = pattern.matcher(search + ","); while (matcher.find()) { builder.with(matcher.group(1), matcher.group(2), matcher.group(3)); } Specification<User> spec = builder.build(); return repo.findAll(spec); } }
Here is a test URL example to test out the API:
http://localhost:8080/users?search=lastName:doe,age>25
And the response:
[{ "id":2, "firstName":"tom", "lastName":"doe", "email":"tom@doe.com", "age":26 }]
8. Conclusion
This tutorial covered a simple implementation that can be the base of a powerful REST query language. We’ve made good use of Spring Data Specifications to make sure we keep the API away from the domain and have the option to handle many other types of operations.
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.