“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.
1 thought on “REST Endpunkte mit Filtern”