“Coffee tastes different today!”
Hugo Bentz
This article is a slightly updated English version of the article REST Endpunkte mit Filtern.
In addition to sorting and pagination, filtering is a fairly common action on REST endpoints. In contrast to the first two, there is no adequate solution for filtering in the Spring Boot framework. With little effort, however, this can be elegantly implemented with the Spring Boot tools.
The following example shows a REST endpoint that loads all known ancestors from the ancestor database. In addition, it is possible to reduce the number of ancestors to members of the Kaiser family from Oldenburg using the request parameter filter.
The solution presented here is very similar to the standard Spring Boot solution for sorting and once again shows the fast results of the Das Mimikri Muster.
http://localhost:8080/ancestors?filter=family,Kaiser&filter=placeOfBirth,Oldenburg
Each filter consists of the respective filter attribute and the corresponding value
to be filtered on. It therefore has the same structure as the Spring Boot parameter sort
. Similar to sorting and pagination, the filter parameter should be available as a method parameter of the endpoint method.
In addition to the pageable
parameter, the following method also handles the self-built filterable
parameter, with which the filter information is passed through.
@GetMapping("/ancestors") public PagedModel<AncestorModel> getAll(Pageable pageable, Filterable filterable, PagedResourcesAssembler<AncestorDto> assembler) { return assembler.toModel(ancestorService.getAll(pageable, filterable), eventModelAssembler); }
The REST Part
A separate HandlerMethodArgumentResolver
is required so that a Filterable
instance can be passed to the endpoint method. This reads the filter parameters from the web request and evaluates the individual parameters.
@Slf4j public class FilterHandlerMethodArgumentResolver implements HandlerMethodArgumentResolver { @Override public boolean supportsParameter(MethodParameter parameter) { return Filterable.class.equals(parameter.getParameterType()); } @Override public Filterable resolveArgument(@NonNull MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { String[] filterParameters = webRequest.getParameterValues("filter"); if (filterParameters == null) { return Filterable.unfiltered(); } List<Filterable.Filter> filters = new ArrayList<>(); for (String filterParameter : filterParameters) { String[] parts = filterParameter.split(","); if (parts.length == 2) { filters.add(new Filter(parts[0], parts[1])); } } Filterable filterable = filters.isEmpty() ? Filterable.unfiltered() : new Filterable(filters); log.info("filterable: {}", filterable); return filterable; } }
If no suitable data is found in the request, the Filterable.unfiltered()
constant is passed. This signals that no filtering is desired. The FilterHandlerMethodArgumentResolver
must be registered via a WebMvcConfigurer
so that it can be used.
@Configuration class FilterableConfiguration implements WebMvcConfigurer { @Override public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) { argumentResolvers.add(new FilterHandlerMethodArgumentResolver()); } }
The JPA Part
Once the filters are in place, they should of course also be used to search the database. This example uses Spring Data JPA and its Specificaction implementation for this purpose.
The getAll
Method of the AncestorService
get a Specification
from a FilterableSpecificationManager
, that needs only the Filterable
Instance and the Ancestor
entity class. The Spring Data ancestorRepository
is also a JpaSpecificationExecutor
and it fetches the Result with its specialized findAll
Method.
@Component public class AncestorService { public Page<Ancestor> getAll(Oageable pageable, Filterable filterable) { Specification<Ancestor> specification = filterableManager.filterBy(filterable, Ancestor.class); return ancestorRepository.findAll(specification, pageable); }
The byFilterable
method of the FilterableSpecificationManager
receives the class of the current JPA entity for the Filterable
and can therefore check the filter attributes against the attributes of the entity. This is possible by using the JPA entity manager and its metamodel. If a filter attribute does not exist in the entity or is not a simple attribute, an IllegalArgumentException is thrown.
Only specifications for simple attributes are generated here. However, it is not a major problem to generate the specifications for embedded objects and sub-entities as well. Presenting all of this would go beyond the scope of this article.
Otherwise, depending on the type, a suitable getXXXSpecification
method is called. In this method, the String
value from the filter is converted into the corresponding type of the Specification
and the Specification
is then generated. It returns an overall Specification
at the end, which is an conjunction of the previously generated Specification instances
public class JpaFilterableSpecificationManager implements FilterableSpecificationManager { private final EntityManager entityManager; public <E> Specification<E> byFilterable(Class<E> entityClass, Filterable filterable) { return new Builder<E>(entityClass, entityManager).byFilterable(filterable); } @RequiredArgsConstructor private static class Builder<T> { private final Class<T> entityClass; private final EntityManager entityManager; private static final Map<Class<?>, Function<Filter, Specification<?>>> functions = Map.ofEntries( Map.entry(Long.class, Builder::getLongSpecification), Map.entry(long.class, Builder::getLongSpecification), Map.entry(Integer.class, Builder::getIntegerSpecification), Map.entry(int.class, Builder::getIntegerSpecification), Map.entry(String.class, Builder::getStringSpecification)); private Specification<T> byFilterable(Filterable filterable) { return filterable.getFilters().stream().map(this::getSpecification).collect(JpaCollectors.toSpecification()); } private Specification<T> getSpecification(Filter filter) { Attribute<? super T, ?> attribute = entityManager.getMetamodel().entity(entityClass) .getAttribute(filter.getProperty()); PersistentAttributeType persistentAttributeType = attribute.getPersistentAttributeType(); if (persistentAttributeType != PersistentAttributeType.BASIC) { throw new IllegalArgumentException("No basic attribute type: " + filter.getProperty()); } Class<?> javaType = attribute.getJavaType(); if (javaType.isEnum()) { return getEnumSpecification(javaType, filter); } Function<Filter, Specification<?>> filterSpecificationFunction = functions.get(javaType); if (filterSpecificationFunction == null) { throw new IllegalArgumentException("Unsupported attribute type: " + filter.getProperty()); } return (Specification<T>) filterSpecificationFunction.apply(filter); } // some more code... } }
To keep the code compact, a custom stream collector was written to merge the individual Specifications
. As already shown in the articles Aufzählungen und andere String-Konkatenationen and Einsammeln und portionieren mit Stream Collector, special collector implementations very often simplify and compress your own code.
@NoArgsConstructor(access = AccessLevel.PRIVATE) public class JpaCollectors { public static <T> Collector<Specification<T>, Accumulator<T>, Specification<T>> toSpecification() { return Collector.of(Accumulator::new, Accumulator::add, (a, b) -> { throw new UnsupportedOperationException("parallel not supported"); }, Accumulator::get); } private static class Accumulator<T> { private Specification<T> result; public void add(Specification<T> specification) { if (result == null) { result = specification; } else { result.and(specification); } } public Specification<T> get() { return result == null ? Specification.<T>where(null) : result; } } }
This already describes the basic implementation of the filters for REST endpoints. Some limitations of the first implementation and possible solutions have already been pointed out in the original article.
The Additions So Far
Multiple Filter Values
First of all, attributes could only be filtered to a single value. But now several values can be specified. There are to variants available.
The first variant is based on the possibility of the HTTP protocol to use the name of a request parameter several times. The values of the parameters are then aggregated into a list.
http://localhost:8080/ancestors?filter=family,Kaiser&filter=family,Kayser&filter=placeOfBirth,Oldenburg
The second variant allows shorter parameter lists because the values are specified separated by commas. For this reason, no commata are allowed in the filter values.
http://localhost:8080/ancestors?filter=family,Kaiser,Kayser&filter=placeOfBirth,Oldenburg
If a filter contains several values, the result is a specification that is a disjunction of the specifications of the individual values.
No Filters On Secrets
As it is possible to filter for every attribute of an entity, this is of course also possible for passwords and other sensitive data. For this reason, all attributes whose names contain password
or secret
are omitted by default.
Configurable String Filters
A text filter should often be case-insensitive or a prefix should be used. There is an annotation @FilterDefault
for the endpoint so that this can be configured.
@GetMapping("/ancestors") @FilterDefault(filter="placeOfBirth", caseSensitive=false) @FilterDefault(filter="name", like=Like.RIGHT) @FilterDefault(filter={"passwordWallet", "secretariat"}) public PagedModel<AncestorModel> getAll(Pageable pageable, Filterable filterable, PagedResourcesAssembler<AncestorDto> assembler) { return assembler.toModel(ancestorService.getAll(pageable, filterable), eventModelAssembler); }
In this example, the filter is case-insensitive for placeOfBirth
. The name
is filtered with a wildcard at the end. In addition to LEFT, the like attribute also allows RIGHT, BOTH and NONE. As wildcards are not always a blessing when searching, wildcards are not allowed in the filter values and will be escaped. The last annotation activates filtering for passwordWallet
and secretariat
, as these two attributes would otherwise be ignored.
In case you still don’t know what the quote is all about. His wife Melitta invented the coffee filter.