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
Grundgerüst und Domänenmodell
- Modulare NestJS-Struktur: die Infrastrukturschicht (
core) getrennt vom Domänenmodul (url). - Prisma-Schema mit einer einzigen Entität
Urlund einem Unique-Index auf dem Kurzlink. DatabaseServiceals injizierbarer Wrapper um den Prisma-Client, damit die Domäne den Client nie direkt importiert.
Funktionsumfang der API
- Fünf Endpunkte: anlegen, auflisten, weiterleiten, aktualisieren, löschen.
UidServiceauf Basis von nanoid, wobei die Länge des Identifiers als Aufrufparameter übergeben wird.UrlExistsPipe— löst eineuidin eine Entität auf und liefert einen einheitlichen 404 über drei Routen hinweg.PaginationService: Ergebnisseite und Gesamtzahl aus ein und derselben Bedingung, fertigenextPage- undprevPage-Links in den Metadaten sowie eine Suche ohne Beachtung der Groß- und Kleinschreibung gleichzeitig über Titel, Beschreibung und Adresse.
Grenzen der Anwendung
AuthGuardüberx-api-keyauf den CRUD-Routen; der Schlüssel wird pergetOrThrowgelesen — die Anwendung bricht beim Start ab, statt mit offenem CRUD hochzufahren.- Ein globaler
ValidationPipemitwhitelist: trueundtransform: 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.
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 ... CASCADEinafterEachstatt eines Neuaufbaus der Datenbank.
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_isreadyundredis-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.
Containerisierung und automatisches Deployment
- Mehrstufiges Dockerfile auf
node:20-alpine: in die Runtime-Schicht wandern nurdist,node_modulesund 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 dessenhead_sha. - Veröffentlichung des Images in der GHCR unter den Tags
mainundsha-<commit>.
Der Prozess
Anforderungen
| Nr. | Anforderung | Begründung |
|---|---|---|
| 1 | POST /url erzeugt einen Link der Form https://url.dphil.ru/{uid} | kurze Domain plus kurzer Identifier |
| 2 | GET /:uid — ein 302-Redirect, der den Klickzähler erhöht | Basisanalytik ohne externe Systeme |
| 3 | Vollständiges CRUD über die Links, durch Authentifizierung geschützt | Links müssen sich bearbeiten und löschen lassen, aber nicht öffentlich |
| 4 | Liste mit Pagination und Volltextsuche | Dutzende Links lassen sich ohne Filter schlecht durchblättern |
| 5 | Ein einheitliches API-Antwortformat | damit Frontend und Skripte nicht zwei verschiedene Formate auswerten müssen |
| 6 | Strukturierte Logs | Logs in der Produktion mit bloßem Auge zu lesen ist keine Option |
| 7 | Tests, die Regressionen auf HTTP-Ebene abfangen | ein Nebenprojekt ohne Tests geht im zweiten Monat kaputt |
| 8 | Deployment ohne manuelle Schritte | sonst 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
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:
@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:
@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:
"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 Headerx-api-key, wobei der Schlüssel pergetOrThrowgelesen 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:
| Ebene | Dateien | Was geprüft wird | Abhängigkeiten |
|---|---|---|---|
| Unit | *.spec.ts | Logik der Services, Guard, Pipe | alles gemockt |
| Integration | *.int-spec.ts | was der Service tatsächlich in die Datenbank schreibt und daraus liest | PostgreSQL in Docker |
| E2E | *.e2e-spec.ts | der gesamte HTTP-Vertrag: Statuscodes, Bodies, Authentifizierung | Anwendung + 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 /:uidgeht weiterhin in die Datenbank. Der nächste Schritt ist,uid → redirectzu 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
Automatisierte Tests im Projekt
Image-Build und Deployment
API-Endpunkte