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.

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.

Schreibe einen Kommentar