Adventskalender – 2025 – Filter & Search – Teil 01

Sven Ruppert

Mit der in [Teil III] beschriebenen Vaadin-Oberfläche steht unserem URL-Shortener erstmals eine voll funktionsfähige Administrationskonsole zur Verfügung. Sie ermöglicht, bestehende Kurzlinks tabellarisch einzusehen und manuell zu verwalten. Doch bereits nach wenigen Dutzenden Einträgen zeigt sich eine klare Grenze: Die vollständige Anzeige aller gespeicherten Mappings ist weder performant noch benutzerfreundlich. Ein effizienter Shortener muss skalieren können – nicht nur beim Erzeugen, sondern auch beim Durchsuchen seiner Daten.

Der Quelltext zu diesem Stand ist auf GitHub unter https://github.com/svenruppert/url-shortener/tree/feature/advent-2025-day-00 zu finden. Der nachfolgende Screenshot zeigt den Stand, mit dem wir beginnen.

Der Fokus dieses ersten Adventskalender-Tages liegt daher auf der Einführung gezielter Filter-, Such- und Paging-Funktionalität. Ziel ist es, die bestehende „Overview“-Ansicht so zu erweitern, dass Benutzer gezielt nach bestimmten Kurzcodes oder URL-Fragmenten suchen, Zeiträume einschränken und die Ergebnisse seitenweise abrufen können. Alle technischen Grundlagen dazu sind in den bisherigen Teilen gelegt:

  1. Teil I – https://javapro.io/de/kurze-links-klare-architektur-ein-url-shortener-in-core-java/
  2. Teil II – https://javapro.io/de/teil-ii-url-shortener/
  3. Teil III – https://javapro.io/de/web-ui-fuer-den-url-shortener/

Der Quelltext zum heutigen Artikel befindet sich auf Github unter

https://github.com/svenruppert/url-shortener/tree/feature/advent-2025-day-01.

Der nachfolgende Screenshot zeigt den darin dargestellten Entwicklungsstand.

Wichtig ist dabei, dass wir keine Framework-Magie einführen, sondern das bestehende System konsequent mit den Bordmitteln des JDK erweitern. Jede neue Klasse, jeder zusätzliche Parameter und jedes API-Detail folgen denselben Prinzipien wie bisher: Klarheit, Typsicherheit und Transparenz. Die Änderungen sind dabei gezielt inkrementell – sie ersetzen keine bestehenden Funktionen, sondern erweitern sie kontrolliert.

Im Folgenden werden wir Schritt für Schritt nachvollziehen, wie diese neue Schicht eingeführt wurde: vom serverseitigen Filtermodell über den erweiterten REST-Endpoint bis hin zur Integration in die Vaadin-UI. Alle neuen Codeabschnitte werden explizit hervorgehoben, damit sich die Unterschiede zu den bisherigen Teilen eindeutig nachvollziehen lassen.

Architektur-Erweiterung

Die in den vorherigen Teilen etablierte Modulstruktur bleibt vollständig erhalten: core, api, client und ui-vaadin bilden weiterhin die zentralen Schichten des Systems. Neu hinzu kommt jedoch eine logische Ebene zwischen dem REST-Endpunkt und der Datenhaltung: das Filtermodell. Es erlaubt, präzise zu beschreiben, welche Daten der Server liefern soll – und nicht länger nur, dass er alle vorhandenen Mappings zurückgibt.

Dieses Architekturprinzip folgt dem in [Teil II] beschriebenen Grundsatz der Responsibility Separation: Jeder Layer soll genau das tun, was in seiner Verantwortung liegt. Die REST-API nimmt Anfragen entgegen, wandelt sie in typsichere Filterobjekte um und übergibt diese an den Store. Der Store wiederum führt die eigentliche Suche, Sortierung und Seiteneinteilung durch. Die UI nutzt lediglich die gefilterten Ergebnisse und bleibt dadurch leichtgewichtig und reaktionsschnell.

Die neue Architektur lässt sich somit als eine kleine, aber entscheidende Erweiterung des bisherigen Datenflusses verstehen:

[UI] → [Client] → [API] → [Filtermodell] → [Store]

Während bisher die Kommunikation zwischen UI und API hauptsächlich in Form vollständiger Datenlisten erfolgte, werden nun gezielt Abfragen mit Parametern übermittelt. Diese Parameter beschreiben Suchmuster, Zeiträume, Sortierungen und Paging-Informationen. Das Ergebnis ist eine deutlich flexiblere und performantere Interaktion, die sich nahtlos in das bestehende System einfügt.

Konzeptionell wurde darauf geachtet, dass alle neuen Komponenten inkrementell integriert werden können. Keine bestehende Funktion wird verändert oder ersetzt. Stattdessen können Clients, die weiterhin die bisherigen Endpunkte nutzen (z. B. /list/all), dies unverändert tun. Neue Clients – etwa die überarbeitete OverviewView – profitieren hingegen sofort von den erweiterten Filtermöglichkeiten.

Diese Entkopplung sorgt nicht nur für Stabilität im laufenden Betrieb, sondern bildet auch die Grundlage für künftige Erweiterungen, etwa persistente Filter oder serverseitige Suchindizes. Die folgenden Abschnitte zeigen nun konkret, wie diese Architektur technisch umgesetzt wurde.

Serverseitige Änderungen

Nachdem die Architektur in den vorangegangenen Teilen stabil definiert wurde, liegt der Fokus dieses Abschnitts auf der Erweiterung der Serverseite. Das Ziel besteht darin, die bestehenden REST-Endpunkte so zu ergänzen, dass sie gezielt gefilterte, sortierte und paginierte Ergebnisse liefern können. Dabei wird die bisherige Struktur des URL-Shorteners nicht verändert, sondern durch klar abgegrenzte Module erweitert.

Die folgenden Unterkapitel beschreiben die wesentlichen neuen Bausteine – vom zentralen UrlMappingFilter über die überarbeiteten Handler bis hin zu den Hilfsklassen, die das Parsing und die Datenaufbereitung übernehmen.

UrlMappingFilter – das neue Filtermodell

Um gezielte Suchanfragen zu ermöglichen, wurde eine neue Klasse eingeführt: UrlMappingFilter. Sie bildet das Herzstück der serverseitigen Erweiterung und definiert alle Parameter, die eine Abfrage präzise beschreiben– vom Textfragment über Datumsbereiche bis hin zu Sortierung und Paging.

Der Aufbau folgt dem in [Teil II] etablierten Prinzip der typsicheren API-Konstruktion konsequent. Anstatt unstrukturierte Query-Parameter direkt im Handler auszuwerten, kapselt UrlMappingFilter alle möglichen Filteroptionen in einem klar definierten Objekt. Dadurch bleibt die REST-Logik schlank und der Code besser testbar.

Ein typisches Filterobjekt kann so aussehen:

var filter = UrlMappingFilter.builder()
    .codePart("ex-")
    .urlPart("docs")
    .createdFrom(Instant.parse("2025-10-01T00:00:00Z"))
    .limit(25)
    .sortBy(SortBy.CREATED_AT)
    .direction(Direction.DESC)
    .build();

Hier werden exemplarisch nur jene Parameter gesetzt, die für eine konkrete Abfrage relevant sind. Nicht gesetzte Werte bleiben null oder leer, was den Builder besonders flexibel macht. Die Klasse selbst ist immutable – nach ihrer Erzeugung kann sie nicht mehr verändert werden.

Die wichtigsten Eigenschaften im Überblick:

  • codePart, urlPart: Textbasierte Filterung (Teilstrings, optional case-sensitive)
  • createdFrom, createdTo: Zeitliche Einschränkung des Ergebnisses
  • offset, limit: Paging-Steuerung (Startindex und Anzahl der Datensätze)
  • sortBy, direction: Sortierkriterien (z. B. CREATED_AT oder SHORT_CODE)

Diese klare Struktur ermöglicht es dem Store, Filter effizient anzuwenden und zukünftige Erweiterungen (etwa status oder user) problemlos zu integrieren. Sie ersetzt den bestehenden Suchmechanismus nicht, sondern erweitert ihn modular. Bestehende Funktionen wie findAll() bleiben erhalten.

Der Einsatz eines expliziten Builder-Patterns sorgt dafür, dass Filterobjekte nur in gültigen Kombinationen erzeugt werden können – ein Prinzip, das sich bereits bei den ShortenRequest- und ShortUrlMapping-Klassen bewährt hat. Zudem wird durch konsequente Nutzung von Optional und klaren Datentypen vermieden, dass leere Strings oder fehlerhafte Werte den Filterprozess stören.

Damit ist die Grundlage geschaffen, um Anfragen in strukturierter Form über die REST-API entgegenzunehmen und gezielt an den Store weiterzureichen. Im nächsten Abschnitt wird gezeigt, wie diese Filterobjekte innerhalb des ListHandler konkret genutzt werden.

ListHandler – erweiterter GET-Endpoint (/list)

Die bisherige Implementierung des ListHandler diente in [Teil II] vor allem dazu, alle gespeicherten Mappings in einer statischen Liste zurückzugeben. Mit wachsendem Datenbestand war dies weder performant noch differenziert genug. Im Zuge der neuen Filterarchitektur wurde der Handler deshalb grundlegend erweitert – allerdings ohne seine bisherigen Endpunkte zu verändern. Bestehende Aufrufe wie /list/all oder /list/expired funktionieren weiterhin unverändert.

Der neue Codepfad erkennt nun Anfragen am Endpunkt /list und interpretiert die übergebenen Query-Parameter. Diese werden in ein UrlMappingFilter-Objekt überführt und anschließend an den Store übergeben. Der Store liefert daraufhin die gefilterte und sortierte Teilmenge zurück.

Ein Ausschnitt aus der neuen Methode:

private String listFiltered(HttpExchange exchange) {
    var query = parseQueryParams(exchange.getRequestURI().getRawQuery());

    int page = parseIntOrDefault(first(query, "page"), 1);
    int size = clamp(parseIntOrDefault(first(query, "size"), 50), 1, 500);
    int offset = Math.max(0, (page - 1) * size);

    var sortBy = parseSort(first(query, "sort"));
    var dir = parseDir(first(query, "dir"));

    boolean codeCase = Boolean.parseBoolean(first(query, "codeCase"));
    boolean urlCase  = Boolean.parseBoolean(first(query, "urlCase"));

    var filter = UrlMappingFilter.builder()
        .codePart(first(query, "code"))
        .codeCaseSensitive(codeCase)
        .urlPart(first(query, "url"))
        .urlCaseSensitive(urlCase)
        .createdFrom(parseInstant(first(query, "from"), true).orElse(null))
        .createdTo(parseInstant(first(query, "to"), false).orElse(null))
        .offset(offset)
        .limit(size)
        .sortBy(sortBy.orElse(null))
        .direction(dir.orElse(null))
        .build();

    int total = store.count(filter);
    var results = store.find(filter);
    var items = results.stream().map(m -> toDto(m, Instant.now())).toList();

    return JsonUtils.toJsonListingPaged("filtered", items.size(), items, page, size, total, sortBy.orElse(null), dir.orElse(null));
}

Diese Methode verdeutlicht, wie sauber sich die neue Filterlogik in den bestehenden Handler integriert. Statt eine vollständige Liste zurückzugeben, wird nun ein paged response object erzeugt, das zusätzliche Metadaten enthält:

{
  "mode": "filtered",
  "page": 2,
  "size": 25,
  "total": 123,
  "sort": "createdAt",
  "dir": "desc",
  "count": 25,
  "items": [ { ... }, { ... } ]
}

Dieses Format bietet zwei entscheidende Vorteile: Zum einen kann die UI gezielt mit Pagination arbeiten, zum anderen lassen sich API-Clients künftig um weitere Parameter erweitern, ohne bestehende Strukturen zu brechen.

Wichtig ist, dass der Handler weiterhin auf Abwärtskompatibilität achtet. Alle älteren Endpunkte werden im selben Kontext registriert und liefern weiterhin vollständige JSON-Listen zurück. Nur /list verwendet das erweiterte Schema.

Der ListHandler wird dadurch zum zentralen Vermittler zwischen API-Request und der Datenfilterung – er übernimmt die Rolle des „Translators“ zwischen HTTP-Parametern und dem internen Filtermodell. Im folgenden Abschnitt wird gezeigt, wie der ergänzende Zähl-Endpoint /list/count dieselbe Logik für reine Mengenabfragen nutzt.

ListCountHandler – schlanker Zähl-Endpoint (/list/count)

Parallel zum erweiterten /list-Endpoint wurde ein zusätzlicher Handler eingeführt: ListCountHandler. Seine Aufgabe ist es, ausschließlich die Gesamtzahl der Treffer für eine gegebene Filterkonfiguration bereitzustellen – ohne die eigentlichen Datensätze zurückzugeben. Diese Trennung wurde bewusst gewählt, um Paging-Operationen in der UI effizient zu gestalten.

Im Gegensatz zu ListHandler überträgt der Zähl-Endpoint also keine vollständigen JSON-Objekte mit Shortcodes und URLs, sondern nur einen kompakten Zählerwert. Der Client kann so vorab ermitteln, wie viele Seiten eine Abfrage umfasst, bevor einzelne Datenseiten geladen werden.

Ein Ausschnitt aus der Implementierung zeigt die Einfachheit des Ansatzes:

@Override
public void handle(HttpExchange exchange) throws IOException {
    if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) {
        exchange.sendResponseHeaders(405, -1);
        return;
    }

    Map<String, List<String>> q = parseQuery(Optional.ofNullable(exchange.getRequestURI().getRawQuery()).orElse(""));

    UrlMappingFilter filter = UrlMappingFilter.builder()
        .codePart(first(q, "code"))
        .codeCaseSensitive(bool(q, "codeCase"))
        .urlPart(first(q, "url"))
        .urlCaseSensitive(bool(q, "urlCase"))
        .createdFrom(parseInstant(first(q, "from")).orElse(null))
        .createdTo(parseInstant(first(q, "to")).orElse(null))
        .build();

    int total = store.count(filter);
    byte[] body = ("{\"total\":" + total + "}").getBytes(StandardCharsets.UTF_8);

    exchange.getResponseHeaders().add("Content-Type", "application/json; charset=utf-8");
    exchange.sendResponseHeaders(200, body.length);
    try (OutputStream os = exchange.getResponseBody()) {
        os.write(body);
    }
}

Die Methode bleibt minimalistisch: kein Paging, keine Sortierung, keine Zusatzdaten. Sie nutzt dieselben Parameter wie der ListHandler, sodass beide Endpoints konsistent verhalten. Der Unterschied liegt lediglich im Rückgabeformat – einem bewussten Entwurf für Effizienz und klare Verantwortlichkeiten.

Ein Beispiel für eine typische Client-Anfrage:

GET /list/count?code=ex-&url=docs

→ { “total”: 42 }

Diese Information kann der Client (z. B. die Vaadin-UI) verwenden, um die maximale Seitenzahl zu berechnen und die Navigationsschaltflächen dynamisch zu aktivieren oder zu deaktivieren. So wird verhindert, dass unnötige Datenmengen übertragen werden, wenn der Benutzer lediglich wissen möchte, wie viele Einträge ein Filter aktuell liefert.

Mit diesem schlanken Endpoint wird der Grundstein für performantes Paging gelegt. Im nächsten Schritt wird gezeigt, wie der Store intern auf diese Filteranfragen reagiert und die Ergebnisse effizient ermittelt.

InMemoryUrlMappingStore – Filtern, Sortieren und Paginieren

Der InMemoryUrlMappingStore, der bereits in [Teil II] als einfache Speicherlösung diente, wurde nun um eine leistungsfähige Filterlogik erweitert. Ziel ist es, auf Grundlage des neuen UrlMappingFilter gezielte Abfragen direkt im Speicher zu verarbeiten – inklusive Sortierung und Paging. Dabei bleibt der Code weiterhin leicht nachvollziehbar und testbar, da keine externe Datenbank zum Einsatz kommt.

Im Vergleich zur ursprünglichen Variante, die ausschließlich findAll() bereitstellte, verfügt die neue Implementierung über zwei zentrale Erweiterungen:

  • find(UrlMappingFilter filter) – liefert eine gefilterte, sortierte und paginierte Liste von Ergebnissen.
  • count(UrlMappingFilter filter) – bestimmt die Anzahl der Elemente, die einem gegebenen Filter entsprechen.

Ein Ausschnitt zeigt den Kern der neuen Logik:

@Override
public List<ShortUrlMapping> find(UrlMappingFilter filter) {
    Objects.requireNonNull(filter, "filter");

    List<ShortUrlMapping> tmp = new ArrayList<>();
    for (ShortUrlMapping mapping : store.values()) {
        if (matches(filter, mapping)) tmp.add(mapping);
    }

    Comparator<ShortUrlMapping> cmp = buildComparator(filter);
    if (cmp != null) {
        tmp.sort(cmp);
        if (filter.direction().orElse(Direction.ASC) == Direction.DESC) {
            Collections.reverse(tmp);
        }
    }

    int from = Math.max(0, filter.offset().orElse(0));
    int lim  = filter.limit().orElse(Integer.MAX_VALUE);
    if (from >= tmp.size()) return List.of();
    int to = Math.min(tmp.size(), from + Math.max(0, lim));
    return tmp.subList(from, to);
}

Der Ablauf gliedert sich in drei Phasen:

  1. Filtern – matches(filter, mapping) prüft Teilstrings (mit optionaler Case-Sensitivity) sowie Zeiträume (createdFrom, createdTo).
  2. Sortieren – das Comparator-Konstrukt ermöglicht die Sortierung nach CREATED_AT, SHORT_CODE, ORIGINAL_URL oder EXPIRES_AT.
  3. Paging – mit offset und limit werden nur die jeweils angeforderten Teilmengen zurückgegeben.

Ein Beispiel verdeutlicht den Effekt:

Filter: codePart=”ex-“, sortBy=CREATED_AT, direction=DESC, offset=0, limit=5

Ergebnis: Die fünf neuesten Shortcodes, die mit “ex-” beginnen.

Die Methode count(filter) ist dagegen bewusst trivial gehalten:

@Override
public int count(UrlMappingFilter filter) {
    int c = 0;
    for (ShortUrlMapping m : store.values()) {
        if (matches(filter, m)) c++;
    }
    return c;
}

So bleibt die Semantik konsistent: count() und find() verwenden dieselbe Filterlogik, unterscheiden sich jedoch in der Rückgabeform. Damit lassen sich Paginierungsinformationen zuverlässig berechnen.

Ein entscheidendes Designziel war die Abwärtskompatibilität: Die ursprüngliche findAll()-Methode bleibt unverändert bestehen. Wird kein Filter übergeben, kann find() intern auf diese zurückgreifen. Somit funktionieren auch ältere Teile der Anwendung weiterhin unverändert.

Die Erweiterung des Stores zeigt exemplarisch, wie sich komplexere Suchmechanismen auf Basis einfacher Java-Konstrukte implementieren lassen – typsicher, transparent und ohne zusätzliche Abhängigkeiten. Im nächsten Abschnitt wird gezeigt, wie diese Logik durch QueryUtils gezielt unterstützt und validiert wird.

QueryUtils – Parsing und Normalisierung von Abfrageparametern

Damit die REST-Endpunkte sauber und robust auf Parameteranfragen reagieren können, wurde eine kleine Hilfsklasse eingeführt: QueryUtils. Sie übernimmt die Aufgabe, rohe Query-Strings aus HTTP-Anfragen in typsichere Werte zu überführen. Dieser Schritt entlastet die Handler-Klassen (ListHandler und ListCountHandler) und sorgt zugleich für einheitliches Verhalten beim Umgang mit Benutzerparametern.

Die Klasse befindet sich im Paket com.svenruppert.urlshortener.api.utils und ist rein statisch aufgebaut. Ihr Zweck besteht darin, fehlerhafte Eingaben abzufangen, Standardwerte festzulegen und Strings in konkrete Enums oder Zahlen umzuwandeln. So wird verhindert, dass unvollständige oder fehlerhafte Query-Parameter zu Exceptions führen oder inkonsistente Zustände verursachen.

Ein Auszug aus der Implementierung:

public final class QueryUtils {

  private QueryUtils() { }

  public static int parseIntOrDefault(String s, int def) {
    try {
      return (s == null || s.isBlank()) ? def : Integer.parseInt(s.trim());
    } catch (NumberFormatException e) {
      return def;
    }
  }

  public static int clamp(int v, int lo, int hi) {
    return Math.max(lo, Math.min(hi, v));
  }

  public static Optional<UrlMappingFilter.SortBy> parseSort(String s) {
    if (s == null) return Optional.empty();
    return switch (s.toLowerCase(Locale.ROOT)) {
      case "createdat" -> Optional.of(UrlMappingFilter.SortBy.CREATED_AT);
      case "shortcode" -> Optional.of(UrlMappingFilter.SortBy.SHORT_CODE);
      case "originalurl" -> Optional.of(UrlMappingFilter.SortBy.ORIGINAL_URL);
      case "expiresat" -> Optional.of(UrlMappingFilter.SortBy.EXPIRES_AT);
      default -> Optional.empty();
    };
  }

  public static Optional<UrlMappingFilter.Direction> parseDir(String s) {
    if (s == null) return Optional.empty();
    return switch (s.toLowerCase(Locale.ROOT)) {
      case "asc" -> Optional.of(UrlMappingFilter.Direction.ASC);
      case "desc" -> Optional.of(UrlMappingFilter.Direction.DESC);
      default -> Optional.empty();
    };
  }
}

Diese kleine Utility-Klasse sorgt für mehrere Dinge gleichzeitig:

  • Resilienz: Fehlerhafte oder fehlende Parameter führen nie zu Exceptions.
  • Konsistenz: Sortierung und Richtung werden systemweit gleich interpretiert.
  • Wartbarkeit: Handler müssen sich nicht mehr um low-level Parsing kümmern.

Gerade das Clamping (clamp(pageSize, 1, 500)) ist ein wichtiges Sicherheitsdetail: Es verhindert übergroße Abfragen und schützt damit sowohl Server als auch UI vor ungewolltem Datenvolumen.

Ein Beispiel verdeutlicht die praktische Wirkung:

Eingabe:  size=-5  →  Ergebnis: size=1

Eingabe:  size=9999 →  Ergebnis: size=500

Damit wird QueryUtils zu einem unauffälligen, aber essenziellen Bestandteil der API-Robustheit. Seine Funktionen folgen demselben Leitprinzip, das sich durch das gesamte Projekt zieht: explizite Typisierung, klare Grenzen und defensive Verarbeitung.

Einleitung zu den API-Endpunkten

Nachdem die serverseitige Logik um Filterung, Sortierung und Paging erweitert wurde, rückt nun die REST-API selbst in den Mittelpunkt. Sie bildet das Bindeglied zwischen den internen Funktionen des URL-Shorteners und den externen Zugriffen des Clients oder der UI. Ziel dieses Kapitels ist es, die neuen und erweiterten Endpunkte im Detail zu beschreiben und ihre Funktion im Gesamtsystem verständlich zu erläutern.

Im Fokus steht dabei nicht nur das Datenformat der Antworten, sondern auch die Semantik der unterstützten Parameter. Beide Aspekte sind entscheidend, um Filterabfragen konsistent, nachvollziehbar und effizient zu gestalten. Durch eine klar strukturierte API können sowohl bestehende Clients weiterarbeiten als auch neue Komponenten – etwa die Vaadin-basierte Administrationsoberfläche – gezielt auf Teilmengen der Daten zugreifen, ohne das System zu überlasten.

Struktur der neuen Endpoints

Mit der Einführung der Filter- und Paging-Mechanismen wurde auch die REST-API des URL-Shorteners erweitert. Neben den bekannten Endpunkten aus [Teil II] stehen nun zwei neue Pfade zur Verfügung, die speziell für gezielte Abfragen vorgesehen sind:

GET /list          – liefert gefilterte und paginierte Ergebnisse
GET /list/count    – liefert nur die Anzahl der Treffer

Beide Endpoints verwenden denselben Satz an Query-Parametern. Während /list die eigentlichen Mappings in strukturierter JSON-Form zurückgibt, dient /list/count als schlanke Möglichkeit, die Gesamtmenge einer Abfrage zu bestimmen. Diese Trennung folgt bewusst dem Prinzip der Single Responsibility und unterstützt zugleich performante UIs, die Seitenweise Daten laden.

Ein typischer Aufruf des neuen /list-Endpoints könnte folgendermaßen aussehen:

GET /list?code=ex-&url=docs&page=2&size=25&sort=createdAt&dir=desc

Das Ergebnis ist ein JSON-Objekt mit den folgenden Schlüsseln:

{
  "mode": "filtered",
  "page": 2,
  "size": 25,
  "total": 123,
  "sort": "createdAt",
  "dir": "desc",
  "count": 25,
  "items": [
    {
      "shortCode": "ex-beta",
      "originalUrl": "https://example.org/blog",
      "createdAt": "2025-10-24T12:45:33Z",
      "expiresAt": "",
      "status": "active"
    }
  ]
}

Der Aufbau des JSON folgt einer klaren Logik: Alle Metadaten zur Anfrage stehen in den ersten Feldern, während das items-Array die eigentlichen Ergebnisse enthält. Dadurch lässt sich die Struktur leicht in UI-Komponenten wie Tabellen, Grids oder Data-Providern einbinden.

Kompatibilität und Stabilität

Bestehende Endpunkte wie /list/all, /list/active und /list/expired bleiben vollständig erhalten. Sie liefern weiterhin unveränderte Antworten, sodass vorhandene Clients keine Anpassungen benötigen. Die neuen Endpunkte fügen sich also additiv ins System ein.

Für neue Clients, insbesondere für die in [Teil III] vorgestellte Vaadin-UI, bietet die Einführung dieser Endpunkte die Grundlage für eine reaktive Datenanzeige. Statt alle Mappings auf einmal zu laden, kann die Oberfläche gezielt nur jene Datensätze anfordern, die vom aktuellen Filter definiert sind.

Mit dieser API-Struktur ist das Fundament gelegt, um in den folgenden Unterkapiteln detailliert auf die unterstützten Parameter und deren Bedeutung einzugehen.

Unterstützte Parameter

Die neuen Endpoints /list und /list/count akzeptieren eine Reihe von Query-Parametern, mit denen sich gezielte Such- und Filterabfragen formulieren lassen. Alle Parameter sind optional und können frei kombiniert werden. Nicht gesetzte Felder führen zu keiner Einschränkung.

ParameterTypBeschreibung
codeStringTeilzeichenfolge des Kurz-Codes. Wird per Default case-insensitive behandelt, sofern codeCase=true nicht gesetzt ist.
codeCasebooleanSteuert, ob die Suche nach Shortcodes groß-/kleinschreibungssensitiv erfolgen soll.
urlStringTeilzeichenfolge innerhalb der Original-URL. Analog zu code kann die Groß-/Kleinschreibung über urlCase gesteuert werden.
urlCasebooleanCase-Sensitivity-Schalter für die URL-Suche.
fromISO-8601-ZeitstempelUntere Grenze des Erstellungszeitraums (inklusive). Akzeptiert auch Datumsangaben ohne Zeitanteil.
toISO-8601-ZeitstempelObere Grenze des Erstellungszeitraums (inklusive).
pageint1-basierte Seitennummer. Standardwert: 1.
sizeintAnzahl der Datensätze pro Seite. Werte außerhalb des Bereichs 1–500 werden automatisch begrenzt.
sortStringSortierschlüssel. Zulässig sind: createdAt, shortCode, originalUrl, expiresAt.
dirStringSortierrichtung: asc oder desc. Standardwert: asc.

Ein vollständiger Request könnte beispielsweise so aussehen:

GET /list?code=ex-&url=docs&from=2025-10-01T00:00:00Z&to=2025-10-25T23:59:00Z&page=2&size=25&sort=createdAt&dir=desc

Die API validiert alle Parameter mithilfe der Klasse QueryUtils. Ungültige oder fehlende Werte werden durch Default-Werte ersetzt, sodass keine Exceptions ausgelöst werden. Diese defensive Strategie sorgt für hohe Stabilität im Dauerbetrieb.

Zusammenspiel der Parameter

  • Wird weder code noch url gesetzt, werden alle Mappings berücksichtigt.
  • from und to definieren einen inklusiven Zeitraum; beide Felder dürfen unabhängig voneinander gesetzt sein.
  • page und size wirken ausschließlich auf die Ergebnisdarstellung, nicht auf die Zählung in /list/count.
  • Kombinationen von sort und dir wirken konsistent auf beide Endpunkte (/list und /list/count).

Ein minimalistisches Beispiel für eine reine Zählabfrage lautet:

GET /list/count?url=example
→ { "total": 12 }

Die API ist so konzipiert, dass zukünftige Parameter problemlos ergänzt werden können. Durch den zentralen UrlMappingFilter müssen neue Felder lediglich dort hinterlegt und im Builder berücksichtigt werden. Dadurch bleibt die Erweiterbarkeit des Systems vollständig gewahrt.

Client-seitige Erweiterungen

Nachdem die Serverseite um flexible Filter- und Paging-Funktionen erweitert wurde, folgt nun die Anpassung des Clients. Ziel dieses Kapitels ist es, zu zeigen, wie die neuen Möglichkeiten zur Datenabfrage in den bestehenden URLShortenerClient integriert wurden, ohne dessen Struktur oder Semantik zu verändern.

Im Mittelpunkt steht dabei der Gedanke der typsicheren Kommunikation zwischen Client und Server. Statt manuelle Query-Strings zusammenzubauen oder lose Maps zu übergeben, kapselt der neue Builder UrlMappingListRequest alle Parameter in einer klaren, objektorientierten Form. Auf dieser Basis können Filter, Sortierung und Paging zentral verwaltet und leicht getestet werden.

Das Kapitel beleuchtet die drei Hauptaspekte der Client-Erweiterung:

  1. Den neuen Anfrage-Builder (UrlMappingListRequest), der Filter- und Paging-Parameter sauber kapselt.
  2. Die erweiterten Methoden im URLShortenerClient verarbeiten diese Requests und leiten sie an die neuen Endpunkte weiter.
  3. Die zugehörige Testklasse, die das Zusammenspiel zwischen Client und Server absichert.

Gemeinsam bilden diese Elemente das Gegenstück zu den serverseitigen Erweiterungen aus dem Kapitel „Serverseitige Änderungen“ und schaffen die Grundlage für eine interaktive Benutzeroberfläche, die gezielt mit gefilterten und paginierten Daten arbeiten kann.

UrlMappingListRequest – Builder für Filter- und Paging-Requests

Damit der Client gezielt mit den neuen Filter- und Paging-Endpunkten kommunizieren kann, wurde die Klasse UrlMappingListRequest eingeführt. Sie fungiert als transportables Anfrageobjekt, das alle relevanten Parameter enthält und bei Bedarf in einen Query-String für die HTTP-Kommunikation übersetzt.

Die Gestaltung folgt den Prinzipien aus [Teil II]: keine externe JSON-Serialisierung, keine Abhängigkeiten von Frameworks – stattdessen reine Java-Logik mit Fokus auf Lesbarkeit und Typsicherheit.

Ein Beispiel verdeutlicht die Verwendung:

var req = UrlMappingListRequest.builder()
    .urlPart("docs")
    .page(2)
    .size(25)
    .sort("createdAt")
    .dir("desc")
    .build();

Die Klasse wandelt diese Angaben anschließend in einen URL-Query-String um, der direkt an den Server gesendet werden kann:

/list?url=docs&page=2&size=25&sort=createdAt&dir=desc

Intern besteht UrlMappingListRequest aus einem Satz einfacher Felder wie codePart, urlPart, from, to, page, size, sort und dir. Der integrierte Builder sorgt dafür, dass nur gültige Kombinationen gebildet werden können. Nicht gesetzte Werte werden beim Serialisieren automatisch ignoriert – leere Parameter erscheinen also nicht im Query-String.

Zwei Methoden sind zentral:

  • toQueryString() – erzeugt den vollständigen Query-String inklusive Paging- und Sortierparameter.
  • toQueryStringForCount() – liefert nur die Filterparameter, ohne Paging- oder Sortierinformationen, für /list/count.

Beide Varianten basieren auf einer internen Hilfsmethode, die Parameter prüft, nach Schlüssel sortiert und über URLEncoder sicher kodiert:

private static String toQuery(Map<String, String> params) {
    return params.entrySet().stream()
        .map(e -> enc(e.getKey()) + "=" + enc(e.getValue()))
        .collect(Collectors.joining("&"));
}

Dieser Aufbau gewährleistet, dass alle Parameter sauber und URL-konform übertragen werden – ein Detail, das insbesondere bei Zeichenketten mit Sonderzeichen (etwa in URLs) essenziell ist.

Damit bildet UrlMappingListRequest die direkte Brücke zwischen Client und API. Es ersetzt keine bestehenden Aufrufe, sondern erweitert die Kommunikationsmöglichkeiten um flexible, typsichere Filterabfragen – ganz im Sinne des modularen Systemdesigns.

Erweiterung des URLShortenerClient – Filter und Zählung

Auf der Client-Seite wurde der URLShortenerClient um zwei wesentliche Funktionen erweitert, um die neuen serverseitigen Endpunkte gezielt anzusprechen: list(UrlMappingListRequest request) und listCount(UrlMappingListRequest request). Diese Methoden ermöglichen, dynamisch auf Filterkriterien zu reagieren, ohne manuelle URL-Zusammenbauten.

Neue Methoden

Die Implementierung folgt dem Prinzip der klaren Trennung von Verantwortlichkeiten: list() kümmert sich um die eigentlichen Daten, listCount() um die Metainformationen.

public List<ShortUrlMapping> list(UrlMappingListRequest request)
    throws IOException {
    final String json = listAsJson(request);
    return parseItemsAsMappings(json);
}

public int listCount(UrlMappingListRequest req)
    throws IOException {
    String qs = (req == null) ? "" : req.toQueryStringForCount();
    var uri = qs.isEmpty()
        ? serverBaseAdmin.resolve(PATH_ADMIN_LIST_COUNT)
        : serverBaseAdmin.resolve(PATH_ADMIN_LIST_COUNT + "?" + qs);

    var con = (HttpURLConnection) uri.toURL().openConnection();
    con.setRequestMethod("GET");
    con.setRequestProperty("Accept", "application/json");

    int sc = con.getResponseCode();
    if (sc != 200) {
        String err = readAllAsString(con.getErrorStream() != null ? con.getErrorStream() : con.getInputStream());
        throw new IOException("Unexpected HTTP " + sc + " for " + uri + " body=" + err);
    }
    try (var is = con.getInputStream()) {
        String body = readAllAsString(is);
        int i = body.indexOf("\"total\"");
        if (i < 0) return 0;
        int colon = body.indexOf(':', i);
        int end = body.indexOf('}', colon + 1);
        String num = body.substring(colon + 1, end).trim();
        return Integer.parseInt(num);
    } finally {
        con.disconnect();
    }
}

Bedeutung der Erweiterung

Durch diese beiden Methoden ist der Client vollständig mit den erweiterten Serverfunktionen kompatibel. Die Filterlogik wird dabei vollständig über das UrlMappingListRequest-Objekt abgebildet, sodass der Aufruf selbst stets klar bleibt:

var req = UrlMappingListRequest.builder()
    .codePart("ex-")
    .urlPart("docs")
    .page(1)
    .size(25)
    .build();

List<ShortUrlMapping> results = client.list(req);
int total = client.listCount(req);

Der Client kann also zunächst die Gesamtmenge abfragen, um die Paginierung zu berechnen, und anschließend gezielt nur die relevanten Datenseiten laden. Das reduziert die Netzwerklast erheblich, insbesondere bei großen Datenbeständen.

Abwärtskompatibilität

Alle bestehenden Methoden wie listAllAsJson() oder listActiveAsJson() bleiben erhalten. Damit bleibt die API binär und semantisch kompatibel – bestehende Tests und Anwendungen funktionieren weiterhin. Die neuen Funktionen fügen sich nahtlos in das bestehende Design ein und bilden zugleich die Grundlage für eine reaktive UI, die Filter und Paging dynamisch auswerten kann.

Testabdeckung des Clients – URLShortenerClientListTest

Um die neuen Client-Funktionen abzusichern, wurde eine eigene Testklasse eingeführt: URLShortenerClientListTest. Sie überprüft das Zusammenspiel zwischen dem Client und einem laufenden ShortenerServer und dient damit als Integrationstest für die gesamte Filter- und Paging-Kette.

Der Test startet einen vollständigen Server auf einem zufälligen Port und kommuniziert anschließend mit dem realen HTTP-Endpunkt. Dadurch wird sichergestellt, dass nicht nur die Clientlogik, sondern auch die Serialisierung, das Request-Routing und die serverseitige Filterung korrekt funktionieren.

Ein Ausschnitt aus dem Testfall:

@Test
@Order(1)
void list_all_and_filtered_by_code_and_url_and_date() throws Exception {
    // Arrange
    var t0 = Instant.now();

    ShortUrlMapping m1 = client.createCustomMapping("ex-alpha", "https://example.com/docs");
    Thread.sleep(10);
    ShortUrlMapping m2 = client.createCustomMapping("ex-beta", "https://example.org/blog");
    Thread.sleep(10);
    ShortUrlMapping m3 = client.createMapping("https://docs.example.com/page");

    var t1 = Instant.now();

    // Act – verschiedene Filter testen
    var reqCode = UrlMappingListRequest.builder().codePart("ex-").build();
    List<ShortUrlMapping> byCode = client.list(reqCode);
    assertTrue(byCode.size() >= 2);

    var reqUrl = UrlMappingListRequest.builder().urlPart("docs").build();
    List<ShortUrlMapping> byUrl = client.list(reqUrl);
    assertTrue(byUrl.stream().anyMatch(m -> m.originalUrl().contains("docs")));

    var reqDate = UrlMappingListRequest.builder().from(t0).to(t1).build();
    List<ShortUrlMapping> byDate = client.list(reqDate);
    assertFalse(byDate.isEmpty());
}

Der Test prüft drei zentrale Anwendungsfälle:

  1. Code-Filterung – nur Shortcodes, die mit einem bestimmten Muster beginnen.
  2. URL-Filterung – URLs, die bestimmte Teilstrings enthalten.
  3. Zeitfilterung – Einträge, die innerhalb eines bestimmten Zeitfensters erstellt wurden.

Paging- und Sortierungstests

Ein zweiter Testabschnitt konzentriert sich auf die Pagination und Sortierung:

@Test
@Order(2)
void list_pagination_and_sorting() throws Exception {
    for (int i = 0; i < 10; i++) {
        client.createMapping("https://example.net/page-" + i);
        Thread.sleep(2);
    }

    var req = UrlMappingListRequest.builder()
        .page(2).size(5)
        .sort("createdAt").dir("desc")
        .build();

    List<ShortUrlMapping> page2 = client.list(req);
    assertEquals(5, page2.size());
    for (int i = 1; i < page2.size(); i++) {
        assertTrue(!page2.get(i).createdAt().isAfter(page2.get(i - 1).createdAt()));
    }
}

Hier wird überprüft, ob die Seitennummerierung korrekt funktioniert und ob die Ergebnisse in der erwarteten Reihenfolge zurückgegeben werden.

Ziel der Tests

Diese Testklasse stellt sicher, dass das Zusammenspiel zwischen Client und Server stabil bleibt, auch wenn sich Parameterkombinationen ändern. Durch das Verwenden eines echten HttpServer-Backends wird eine realistische Umgebung simuliert, ohne externe Abhängigkeiten. Das Ergebnis ist eine hohe Vertrauenswürdigkeit der Implementierung bei vollständiger Abdeckung aller kritischen Pfade.

Die URLShortenerClientListTest-Klasse bildet damit den Abschluss des Client-Kapitels: Sie belegt, dass das System als Ganzes – von der Filterdefinition über die HTTP-Kommunikation bis hin zur Ergebnisverarbeitung – konsistent funktioniert.

Im nächsten Teil werden wir uns dann die Integration in die Vaadin UI ansehen.

Cheers Sven

Total
0
Shares
Previous Post

URL‑Shortener Adventskalender 2025

Next Post

Adventskalender – 2025 – Filter & Search – Teil 02

Related Posts