Start
Alle Projekte

URL Shortener

Link-Shortener-API mit Redirects und Klick-Analytics

Rolle

Backend-Entwickler

Jahr

2024

Stack

NestJSTypeScriptPostgreSQLPrismaDockerGitHub ActionsREST API

Die Aufgabe

Ich brauchte Kurzlinks auf meiner eigenen Domain — für Lebenslauf, Portfolio, Artikel und Mailings. Fertige Dienste wie bit.ly lösen das durchaus, allerdings mit Einschränkungen:

  • der Link lebt auf einer fremden Domain und stirbt mit einem fremden Tarif;
  • die Klickstatistik bleibt in einem fremden Dashboard eingeschlossen;
  • es fehlt ein bequemer programmatischer Zugriff für eigene Szenarien — etwa einen Link aus einem Skript oder aus dem CMS der eigenen Website zu erzeugen.

Daraus die Aufgabenstellung: ein eigener API-Dienst, der sich von überall aufrufen lässt und einen Hosting-Wechsel übersteht — also portabel, reproduzierbar und mit automatischem Deployment.

Die Phasen

01

Grundgerüst und Domänenmodell

  • Modulare NestJS-Struktur: die Infrastrukturschicht (core) getrennt vom Domänenmodul (url).
  • Prisma-Schema mit einer einzigen Entität Url und einem Unique-Index auf dem Kurzlink.
  • DatabaseService als injizierbarer Wrapper um den Prisma-Client, damit die Domäne den Client nie direkt importiert.
02

Funktionsumfang der API

  • Fünf Endpunkte: anlegen, auflisten, weiterleiten, aktualisieren, löschen.
  • UidService auf Basis von nanoid, wobei die Länge des Identifiers als Aufrufparameter übergeben wird.
  • UrlExistsPipe — löst eine uid in eine Entität auf und liefert einen einheitlichen 404 über drei Routen hinweg.
  • PaginationService: Ergebnisseite und Gesamtzahl aus ein und derselben Bedingung, fertige nextPage- und prevPage-Links in den Metadaten sowie eine Suche ohne Beachtung der Groß- und Kleinschreibung gleichzeitig über Titel, Beschreibung und Adresse.
03

Grenzen der Anwendung

  • AuthGuard über x-api-key auf den CRUD-Routen; der Schlüssel wird per getOrThrow gelesen — die Anwendung bricht beim Start ab, statt mit offenem CRUD hochzufahren.
  • Ein globaler ValidationPipe mit whitelist: true und transform: true, dazu @IsUrl() auf dem Redirect-Feld.
  • TransformResponseInterceptor — ein einheitlicher Umschlag, { data } beziehungsweise { data, meta }.
  • helmet() und strukturierte winston-Logs: JSON in der Produktion, farbige Ausgabe in der Entwicklung.
04

Tests auf drei Ebenen

  • Unit-Tests gegen Mocks, Integrationstests gegen eine echte PostgreSQL, E2E-Tests über HTTP durch die gesamte Anwendung.
  • Zwei jest-Konfigurationen: schnelle Tests über src, die schweren hinter einem eigenen Kommando mit --runInBand.
  • Isolation über TRUNCATE ... CASCADE in afterEach statt eines Neuaufbaus der Datenbank.
05

Reproduzierbare Testumgebung

  • Eine eigene Compose-Datei für Tests: eigene Ports (5444 und 6380), eigene Container, eigenes Netzwerk, eigene Volumes und eine eigene .env.test.
  • Healthchecks mit pg_isready und redis-cli ping, kombiniert mit dem Start über --wait.
  • Ein eigenes Warteskript: zuerst der TCP-Port, dann ein echtes prisma.$connect() — bis zu 120 Versuche im Sekundentakt.
06

Containerisierung und automatisches Deployment

  • Mehrstufiges Dockerfile auf node:20-alpine: in die Runtime-Schicht wandern nur dist, node_modules und die Prisma-Artefakte — zusammen mit dem Schema und dem Migrationsordner.
  • Umstellung der CI auf denselben Paketmanager wie lokal, die Installation ausgelagert in eine Composite Action.
  • Deployment ausgelöst durch das workflow_run-Ereignis des Test-Workflows, mit Checkout exakt jenes Commits über dessen head_sha.
  • Veröffentlichung des Images in der GHCR unter den Tags main und sha-<commit>.

Der Prozess

Anforderungen

Nr.AnforderungBegründung
1POST /url erzeugt einen Link der Form https://url.dphil.ru/{uid}kurze Domain plus kurzer Identifier
2GET /:uid — ein 302-Redirect, der den Klickzähler erhöhtBasisanalytik ohne externe Systeme
3Vollständiges CRUD über die Links, durch Authentifizierung geschütztLinks müssen sich bearbeiten und löschen lassen, aber nicht öffentlich
4Liste mit Pagination und VolltextsucheDutzende Links lassen sich ohne Filter schlecht durchblättern
5Ein einheitliches API-Antwortformatdamit Frontend und Skripte nicht zwei verschiedene Formate auswerten müssen
6Strukturierte LogsLogs in der Produktion mit bloßem Auge zu lesen ist keine Option
7Tests, die Regressionen auf HTTP-Ebene abfangenein Nebenprojekt ohne Tests geht im zweiten Monat kaputt
8Deployment ohne manuelle Schrittesonst werden Updates schlicht nie ausgerollt

Architektur

Eine modulare NestJS-Struktur, die Infrastrukturschicht und Domänenmodul voneinander trennt:

src/
├── core/                     # Infrastruktur (globales Modul)
│   ├── cache/                # Redis über Cacheable + keyv
│   ├── logger/               # winston: JSON in Produktion, farbig in Entwicklung
│   ├── middleware/logger/    # Logging jeder HTTP-Anfrage
│   └── interceptors/         # einheitliches Antwortformat { data, meta }
├── database/                 # Prisma-Client als injizierbarer Service
├── auth/                     # AuthGuard über x-api-key
├── modules/url/              # Domänenmodul: Controller, Service, DTOs, Pipe
├── services/
│   ├── uid/                  # Erzeugung des Identifiers (nanoid)
│   └── pagination/           # Pagination und Filterung
└── utils/                    # Warten auf die Datenbank, Test-Fixtures

Das Leitprinzip: das Domänenmodul weiß nichts über die Infrastruktur. UrlService arbeitet mit DatabaseService, UidService, PaginationService und ConfigService — alle werden per DI injiziert und lassen sich deshalb in Unit-Tests durch Mocks ersetzen, ohne eine einzige Zeile Infrastrukturcode anzufassen.

Der Weg einer Anfrage durch die Anwendung:

Datenmodell

prisma
model Url {
  id          Int      @id @default(autoincrement())
  redirect    String              // wohin weitergeleitet wird
  url         String   @unique    // der vollständige Kurzlink selbst
  title       String
  description String?
  clicks      Int      @default(0)
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
}

Der Unique-Index auf url ist nicht nur bequem für die Suche, sondern schützt auf Datenbankebene vor Kollisionen des Generators: zwei identische Kurzlinks lassen sich physisch nicht speichern.


Zentrale Entscheidungen

1. Die Existenzprüfung liegt in einer Pipe, nicht im Controller

Drei Endpunkte — GET /:uid, PATCH /url/:uid und DELETE /url/:uid — hätten jeweils mit denselben fünf Zeilen „suche anhand der uid, wirf einen 404, falls nichts da ist“ begonnen. Stattdessen gibt es eine eigene Pipe, die den String-Routenparameter direkt in eine Entität verwandelt:

ts
@Injectable()
export class UrlExistsPipe implements PipeTransform {
  constructor(private readonly urlService: UrlService) {}

  async transform(uid: any) {
    const redirectUrl = await this.urlService.findOne(uid);
    if (!redirectUrl) throw new NotFoundException('Url not found');
    return redirectUrl;
  }
}

Im Controller bleibt eine deklarative Signatur — der Handler bekommt ein fertiges Objekt:

ts
@Get(':uid')
findOne(@Param('uid', UrlExistsPipe) url: Url, @Res() res: Response) {
  this.urlService.incrementClicks(+url.id);
  return res.redirect(url.redirect);
}

Ein willkommener Nebeneffekt: der 404 ist auf allen Routen garantiert identisch, und man kann ihn nicht vergessen.

2. Ein einheitlicher Antwortvertrag über einen globalen Interceptor

TransformResponseInterceptor verpackt jedes Ergebnis in { data } und ein paginiertes in { data, meta }. Die Services geben dabei reine Domänenobjekte zurück und wissen nichts über das Transportformat.

3. Pagination mit fertigen Links auf die Nachbarseiten

PaginationService ermittelt Ergebnismenge und Gesamtzahl über ein und dasselbe where und liefert in meta nicht nur Zahlen, sondern fertig zusammengesetzte URLs der nächsten und der vorherigen Seite:

json
"meta": {
  "totalCount": 42, "currentPage": 2, "perPage": 10, "totalPages": 5,
  "nextPage": "https://url.dphil.ru/url?limit=10&page=3",
  "prevPage": "https://url.dphil.ru/url?limit=10&page=1"
}

Die Suche ist ein contains ohne Beachtung der Groß- und Kleinschreibung gleichzeitig über title, description und url, damit eine einzige Suchzeile das Szenario „finde den Link, den ich im Mai angelegt habe“ abdeckt.

4. Validierung an der Grenze der Anwendung

Ein globaler ValidationPipe mit whitelist: true und transform: true, dazu DTOs auf Basis von class-validator: @IsUrl() auf dem Redirect-Feld, @Min(1) und Typumwandlung für die Query-Parameter der Pagination. Alles, was im DTO nicht vorgesehen ist, wird aus dem Request-Body entfernt — ein Client kann sich nicht selbst clicks: 9999 eintragen. Dieses Szenario ist durch einen eigenen E2E-Test abgesichert.

5. Sicherheit an den Grenzen

  • helmet() auf Anwendungsebene;
  • AuthGuard über den Header x-api-key, wobei der Schlüssel per getOrThrow gelesen wird — die Anwendung bricht beim Start ab, wenn der Schlüssel fehlt, statt still mit offenem CRUD zu laufen;
  • öffentlich bleibt einzig der Redirect, alles andere ist geschlossen.

6. Logging, das maschinell auswertbar ist

winston in zwei Modi: in der Entwicklung ein farbiges, für Menschen lesbares Format, in der Produktion JSON mit Zeitstempel, das sich direkt in jeden Log-Collector einfügt. Die Middleware schreibt jede abgeschlossene Antwort und wählt die Stufe anhand des Statuscodes (5xx → error, 4xx → warn, alles Übrige → info); in der Testumgebung schaltet sie sich ab, um die Ausgabe von jest nicht zuzumüllen.

Das Redirect-Szenario im Ganzen


Testing: drei Ebenen statt einer

Der größte Teil der Arbeit. Die Tests sind nach Kosten und Zweck aufgeteilt:

EbeneDateienWas geprüft wirdAbhängigkeiten
Unit*.spec.tsLogik der Services, Guard, Pipealles gemockt
Integration*.int-spec.tswas der Service tatsächlich in die Datenbank schreibt und daraus liestPostgreSQL in Docker
E2E*.e2e-spec.tsder gesamte HTTP-Vertrag: Statuscodes, Bodies, AuthentifizierungAnwendung + Datenbank + Redis

Schnelle und schwere Tests sind auf zwei jest-Konfigurationen verteilt: npm run test führt nur die Unit-Tests über src aus, während npm run test:e2e die Umgebung hochfährt und Integrations- sowie E2E-Tests in einem Durchlauf mit --runInBand startet, damit sie sich nicht um dieselbe Datenbank streiten.

Die Isolation zwischen den Tests erfolgt nicht durch einen Neuaufbau der Datenbank, sondern durch ein TRUNCATE ... CASCADE über alle Tabellen außer _prisma_migrations in afterEach. Das ist deutlich schneller, als vor jedem Test die Migrationen einzuspielen, und trotzdem startet jeder Test aus einem sauberen Zustand.

Die E2E-Suite deckt auch die unangenehmen Fälle ab, die ein Refactoring leicht zerstört: Anfrage ohne API-Key → 401, mit falschem Schlüssel → 401, leerer Body → 400, ungültige URL in redirect → 400, Versuch zusätzliche Felder mitzugeben → sie werden von der Validierung verworfen.


Infrastruktur und CI/CD

Docker. Ein mehrstufiger Build auf node:20-alpine: in der Build-Stufe werden die Abhängigkeiten über corepack/pnpm mit --frozen-lockfile installiert, der Prisma-Client generiert und die Anwendung kompiliert; in das Runtime-Image wandern nur dist, node_modules und die Prisma-Artefakte. schema.prisma und der Ordner migrations werden gesondert in das finale Image kopiert — ohne sie hat prisma migrate deploy in der Produktion schlicht keine Arbeitsgrundlage.

Zwei Compose-Dateien. Eine reguläre für die lokale Entwicklung und eine eigene für Tests — mit eigenen Ports (5444 für Postgres, 6380 für Redis), eigener .env.test, eigenem Netzwerk und Healthchecks auf beiden Diensten.

Das Deployment wird nicht durch einen Push ausgelöst, sondern durch das workflow_run-Ereignis des Test-Workflows, und es checkt genau den Commit aus, der getestet wurde. Eine rote CI kann physisch nicht in die Produktion gelangen. Das Image wird in der GHCR gleich unter mehreren Tags veröffentlicht: main für „die neueste Version“ und sha-<commit> — damit ein Rollback eine Frage einer einzigen Zeile ist und nicht eines Rebuilds.


Schwierigkeiten und ihre Lösung

1. Sporadisch fehlschlagende E2E-Tests in der CI. Lokal alles grün, in Actions gelegentliche Verbindungsfehler zur Datenbank. Die Ursache ist ein Klassiker: docker compose up -d gibt die Kontrolle zurück, sobald der Container läuft, nicht sobald Postgres bereit ist, Verbindungen anzunehmen. Die Lösung besteht aus zwei Schichten: Healthchecks mit pg_isready und redis-cli ping plus Start mit dem Flag --wait, dazu ein eigenes Warteskript, das zuerst die Erreichbarkeit des TCP-Ports prüft und anschließend ein echtes prisma.$connect() ausführt — bis zu 120 Versuche im Sekundentakt. Dass die Prüfung gerade über Prisma läuft, ist wesentlich: ein offener Port bedeutet noch nicht, dass die Datenbank für jenen Benutzer erreichbar ist, unter dem die Migrationen laufen.

2. Portkonflikte zwischen Entwicklungs- und Testumgebung. Der Testlauf kam der lokalen Entwicklungsdatenbank in die Quere. Ich habe die Umgebungen vollständig getrennt: eigene Compose-Datei, fest vergebener Port 5444, eigene Container-, Netzwerk- und Volume-Namen sowie eine eigene .env.test, die über dotenv-cli eingebunden wird.

3. Prisma im Container. Der erste funktionierende Image-Build scheiterte in der Produktion beim Einspielen der Migrationen — weder das Schema noch die Migrationen landeten in der Runtime-Schicht. Hinzu kommt, dass die Client-Generierung innerhalb des Builds für die Zielplattform stattfinden muss und nicht von der Entwicklermaschine mitgeschleppt werden darf: die Installation läuft mit --ignore-scripts, und prisma generate wird explizit zum richtigen Zeitpunkt aufgerufen.

4. Umstellung der CI auf pnpm. Lokal nutzte das Projekt pnpm, die Workflows dagegen npm; dadurch wurde die Lockfile nicht eingehalten und die CI zog Abhängigkeitsversionen, die ich nie hatte. Ich habe alles auf einen Paketmanager vereinheitlicht und die Installation in eine wiederverwendbare Composite Action ausgelagert.

5. Redis-Konfiguration aus Umgebungsvariablen. Verschiedene Hoster liefern verschiedene Kombinationen: mal nur ein Passwort, mal ein Paar aus Benutzername und Passwort, mal keines von beidem. Der Verbindungsstring wird dynamisch zusammengesetzt, mit URL-kodierten Zugangsdaten, sodass Sonderzeichen im Passwort die Verbindung nicht zerstören.

Wie es weitergeht

Eine ehrliche Liste dessen, was ich über das Projekt weiß und wohin es sich entwickelt:

  • Eine kurze Domain statt einer Subdomain.
  • Caching der Redirects. Redis und ein Wrapper darum sind bereits eingebunden, doch der heiße Pfad GET /:uid geht weiterhin in die Datenbank. Der nächste Schritt ist, uid → redirect zu cachen und den Cache bei Update und Delete zu invalidieren: das ist die häufigste Anfrage des Dienstes und ein idealer Kandidat für einen Cache.
  • Der Klickzähler. Das Inkrement blockiert den Redirect bewusst nicht, hat dadurch aber auch keine Zustellgarantie. Die richtige Weiterentwicklung ist Pufferung und stapelweises Schreiben.
  • Rate Limiting auf dem öffentlichen Redirect und Prüfung der Zieladressen — als Schutz davor, dass der Dienst als offener Redirector missbraucht wird.
  • Benutzer statt eines einzelnen API-Keys, falls den Dienst jemals jemand außer mir nutzt.
  • Cursor-basierte Pagination statt Offset — sobald genug Links vorhanden sind, damit der Unterschied spürbar wird.

Die Ergebnisse

40

Automatisierte Tests im Projekt

≈1 Min. 15 s

Image-Build und Deployment

5

API-Endpunkte

Screens