REST endpoints with filters

“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.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
http://localhost:8080/ancestors?filter=family,Kaiser&filter=placeOfBirth,Oldenburg
http://localhost:8080/ancestors?filter=family,Kaiser&filter=placeOfBirth,Oldenburg
http://localhost:8080/ancestors?filter=family,Kaiser&filter=placeOfBirth,Oldenburg

Each filter consists of the respective filter attribute and the corresponding

value
value to be filtered on. It therefore has the same structure as the Spring Boot parameter
sort
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
pageable parameter, the following method also handles the self-built
filterable
filterable parameter, with which the filter information is passed through.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
@GetMapping("/ancestors")
public PagedModel<AncestorModel> getAll(Pageable pageable, Filterable filterable, PagedResourcesAssembler<AncestorDto> assembler) {
return assembler.toModel(ancestorService.getAll(pageable, filterable), eventModelAssembler);
}
@GetMapping("/ancestors") public PagedModel<AncestorModel> getAll(Pageable pageable, Filterable filterable, PagedResourcesAssembler<AncestorDto> assembler) { return assembler.toModel(ancestorService.getAll(pageable, filterable), eventModelAssembler); }
@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
HandlerMethodArgumentResolver is required so that a
Filterable
Filterable instance can be passed to the endpoint method. This reads the filter parameters from the web request and evaluates the individual parameters.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
@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;
}
}
@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; } }
@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()
Filterable.unfiltered() constant is passed. This signals that no filtering is desired. The
FilterHandlerMethodArgumentResolver
FilterHandlerMethodArgumentResolver must be registered via a
WebMvcConfigurer
WebMvcConfigurer so that it can be used.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
@Configuration
class FilterableConfiguration implements WebMvcConfigurer {
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
argumentResolvers.add(new FilterHandlerMethodArgumentResolver());
}
}
@Configuration class FilterableConfiguration implements WebMvcConfigurer { @Override public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) { argumentResolvers.add(new FilterHandlerMethodArgumentResolver()); } }
@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
getAll Method of the
AncestorService
AncestorService get a
Specification
Specification from a
FilterableSpecificationManager
FilterableSpecificationManager, that needs only the
Filterable
Filterable Instance and the
Ancestor
Ancestor entity class. The Spring Data
ancestorRepository
ancestorRepository is also a
JpaSpecificationExecutor
JpaSpecificationExecutor and it fetches the Result with its specialized
findAll
findAll Method.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
@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);
}
@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); }
@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
byFilterable method of the
FilterableSpecificationManager
FilterableSpecificationManager receives the class of the current JPA entity for the
Filterable
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
getXXXSpecification method is called. In this method, the
String
String value from the filter is converted into the corresponding type of the
Specification
Specification and the
Specification
Specification is then generated. It returns an overall
Specification
Specification at the end, which is an conjunction of the previously generated Specification instances

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
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...
}
}
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... } }
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
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.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
@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;
}
}
}
@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; } } }
@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.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
http://localhost:8080/ancestors?filter=family,Kaiser&filter=family,Kayser&filter=placeOfBirth,Oldenburg
http://localhost:8080/ancestors?filter=family,Kaiser&filter=family,Kayser&filter=placeOfBirth,Oldenburg
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.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
http://localhost:8080/ancestors?filter=family,Kaiser,Kayser&filter=placeOfBirth,Oldenburg
http://localhost:8080/ancestors?filter=family,Kaiser,Kayser&filter=placeOfBirth,Oldenburg
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
password or
secret
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
@FilterDefault for the endpoint so that this can be configured.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
@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);
}
@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); }
@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
placeOfBirth. The
name
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
passwordWallet and
secretariat
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.

Leave a Comment