REST Endpunkte mit Filtern

„Der Kaffee schmeckt heut anders!“

Hugo Bentz

Neben dem Sortieren und Paginieren ist das Filtern eine recht häufige Aktion auf REST Endpunkten. Im Gegensatz zu den beiden erstgenannten gibt es aber für das Filtern keine adäquate Lösung im Spring Boot Framework. Mit geringen Aufwand kann dies aber elegant mit den Spring Boot Hilfsmitteln ergänzt werden.

Im folgenden Beispiel ist ein REST Endpunkt dargestellt, der alle bekannten Ahnen aus der Ahnendatenbank lädt. Zusätzlich ist es hier über die Request Parameter filter möglich die Menge der Ahnen auf Mitglieder der Familie Kaiser aus Oldenburg zu reduzieren.

Die hier vorgestellte Lösung ist stark an der Standard Spring Boot Lösung für das Sortieren angelehnt und zeigt wieder einmal die schnellen Resultate des Mimikri Musters.

http://localhost:8080/ancestors?filter=family,Kaiser&filter=placeOfBirth,Oldenburg

Jeder Filter besteht aus dem jeweiligen Filterattribut und dem entsprechenden Wert auf dem gefilter werden soll. Damit entspricht es vom Aufbau dem Spring Boot Parameter sort. Ähnlich wie beim Sortieren und Paginieren soll der Filter Parameter als Methodenparameter der Endpunkt Methode zur Verfügung stehen.

Neben dem pageable Parameter beherrscht die folgende Methode auch den selbstgebauten filterable Parameter, mit dem die Filterangaben durchgereicht werden.

@GetMapping("/ancestors")
public PagedModel<AncestorModel> getAll(Pageable pageable, Filterable filterable, PagedResourcesAssembler<AncestorDto> assembler) {
  return assembler.toModel(ancestorService.getAll(pageable, filterable), eventModelAssembler);
}

Damit eine Filterable Instanz an die Endpunkt Methode übergeben werden kann, wird ein eigener HandlerMethodArgumentResolver benötigt. Dieser liest die Filterparameter aus dem Web-Request und wertet die einzelnen Parameter aus.

@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;
  }
}

Werden keine passenden Daten im Request gefunden, dann wird die Filterable.unfiltered() Konstante übergeben. Diese signalisiert, dass kein Filtern erwünscht ist. Damit der FilterHandlerMethodArgumentResolver genutzt werden kann muss er noch über einen WebMvcConfigurer registriert werden.

@Configuration
class FilterableConfiguration implements WebMvcConfigurer {

  @Override
  public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
    argumentResolvers.add(new FilterHandlerMethodArgumentResolver());
  }
}

Nachdem die Filter vorhanden sind, soll mit ihnen natürlich auch auf der Datenbank gesucht werden. Dazu verwendet dieses Beispiel Spring Data JPA und dessen Specifiaction Implementierung.

Die Methode byFilterable erzeugt für jeden einzelnen Filter der Filterable Instanz eine entsprechende Specification Instanz und liefert am Ende eine gesamt Specification, die eine Und-Verknüpfung der vorher generierten Specification Instanzen ist.

static Specification<EventEntity> byFilterable(Filterable filterable) {
  return filterable.getFilters().stream().map(AncestorRepository::getSpecification)
      .collect(JpaCollectors.toSpecification());
}

private static Specification<Ancestor> getSpecification(Filter filter) {
  switch (filter.getProperty()) {
    case "name", "placeOfBirth" ->
        return (Root<Ancestor> root, CriteriaQuery<?> query, CriteriaBuilder criteriaBuilder)
            -> criteriaBuilder.like(root.get(filter.getProperty()), filter.getValue());
    // other filters...
    default -> throw new IllegalArgumentException("Unexpected value: " + filter.getProperty());
  }
}

Um den Code kompakt zu halten, wurde zum zusammenfügen der einzelnen Specification ein eigener Stream Collector geschrieben. Wie schon in den Beiträgen Aufzählungen und andere String-Konkatenationen und Einsammeln und portionieren mit Stream Collector gezeigt, vereinfachen und komprimieren spezielle Collector Implementierung sehr häufig den eigenen 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;
    }
  }
}

Damit ist die Implementierung einer Filterlösung für Spring Boot REST Endpunkte auch schon an ihrem Ende angelangt. Diverse Verbesserungen sind durch die Verwendung von Annotationen ähnlich der @SortDefault Annotation möglich. Eine @FilterDefault Annotation am Endpunkt könnte die dort verwendeten Filter modifizieren.

@GetMapping("/ancestors")
@FilterDefault(filter="placeOfBirth", caseSensitive=false)
@FilterDefault(filter="name", contained=true)
@FilterDefault(filter="dateOfBirth", truncated=ChronoUnit.YEAR)
@FilterDefault(filter="dateOfDeath", duration="P2Y")
public PagedModel<AncestorModel> getAll(Pageable pageable, Filterable filterable, PagedResourcesAssembler<AncestorDto> assembler) {
  return assembler.toModel(ancestorService.getAll(pageable, filterable), eventModelAssembler);
}

Mit diesen drei Einträgen können sich die Filter soweit ändern, dass beim Filtern des Geburtsortes die Groß- und Kleinschreibung ignoriert wird, beim Namen auch Kaiserlein vorkommen kann, nur das Geburtsjahr beachtet wird und beim Sterbedatum eine Varianz um zwei Jahre möglich ist.

Zu guter Letzt nur noch der Hinweis zum obigen Zitat. Es ist nicht überliefert ob Hugo diese Worte jemals an seine Frau Melitta gerichtet hat, aber es wäre gut möglich.

Schreibe einen Kommentar