Einen REST-API-Endpunkt sauber bauen: Validierung, Fehler, Dokumentation

Was einen guten REST-Endpunkt ausmacht: Eingaben mit zod prüfen, Fehler einheitlich melden, Statuscodes richtig setzen, per OpenAPI dokumentieren.

Veröffentlicht
Autor
Mario Gvozden
Lesezeit
7 Minuten

Ein neuer API-Endpunkt ist schnell geschrieben. Ein paar Zeilen, die Daten entgegennehmen und in die Datenbank schreiben, und der Partner kann loslegen. Probleme zeigen sich erst später: Ein Partner schickt ein Datum im falschen Format, die Datenbank wirft eine Ausnahme, der Client bekommt einen nichtssagenden Fehler 500, und niemand weiß mehr, welche Felder eigentlich Pflicht waren.

Ein sauberer Endpunkt unterscheidet sich vom schnellen vor allem in drei Punkten: Er prüft jede Eingabe, er meldet Fehler einheitlich und verständlich, und er ist dokumentiert. Dieser Beitrag zeigt, wie das in einem Node.js-Projekt mit TypeScript aussieht.

Vor dem Code: den Vertrag festlegen

Ein Endpunkt ist ein Vertrag zwischen Ihrem System und denen, die es aufrufen. Klären Sie vorab:

  • Ressource und Methode: Was wird gelesen oder angelegt? POST /customers legt einen Kunden an, GET /customers/{id} liest einen.
  • Eingabefelder: Name, Typ, Pflicht oder optional, erlaubte Werte, maximale Länge.
  • Antwort im Erfolgsfall: Welche Felder kommen zurück, mit welchem Statuscode?
  • Fehlerfälle: Was passiert bei ungültigen Daten, bei Duplikaten, ohne Berechtigung?
  • Authentifizierung: API-Schlüssel, OAuth oder Sitzung?

Diese Liste ist zugleich die Grundlage für die Dokumentation und für die Abnahmekriterien.

Validierung mit zod

Jede Eingabe von außen ist erst einmal unbekannt. TypeScript-Typen helfen hier nicht, denn sie existieren zur Laufzeit nicht mehr. Eine Bibliothek wie zod beschreibt die erwartete Form der Daten einmal und prüft sie beim Aufruf. Gleichzeitig entsteht daraus der TypeScript-Typ.

import { z } from "zod";

export const CreateCustomer = z.object({
  name: z.string().trim().min(1).max(200),
  email: z.string().trim().toLowerCase().email(),
  country: z.enum(["DE", "AT", "CH"]),
  vatId: z.string().regex(/^[A-Z]{2}[0-9A-Z]{2,12}$/).optional(),
});

export type CreateCustomer = z.infer<typeof CreateCustomer>;

Ein paar Grundsätze:

  • Längen begrenzen. Ein Namensfeld ohne Obergrenze lädt dazu ein, Megabytes an Text zu schicken.
  • Erlaubte Werte aufzählen, wo es möglich ist, statt beliebige Zeichenketten zu akzeptieren.
  • Unbekannte Felder ignorieren oder ablehnen, aber bewusst. z.object entfernt unbekannte Felder standardmäßig aus dem Ergebnis. Das verhindert, dass jemand ein Feld wie isAdmin mitschickt und es versehentlich in der Datenbank landet.
  • Normalisieren (Leerzeichen entfernen, E-Mail klein schreiben), bevor Duplikate geprüft werden.

Der Endpunkt in Express

Mit Express 5 werden Fehler aus asynchronen Handlern automatisch an die Fehlerbehandlung weitergereicht. Ein Endpunkt zum Anlegen eines Kunden:

import express, { type ErrorRequestHandler } from "express";
import { CreateCustomer } from "./schemas";
import { db } from "./db";

const app = express();
app.use(express.json({ limit: "100kb" }));

function problem(res: express.Response, status: number, title: string, extra = {}) {
  res.status(status).type("application/problem+json")
    .json({ type: "about:blank", title, status, ...extra });
}

app.post("/customers", async (req, res) => {
  const parsed = CreateCustomer.safeParse(req.body);
  if (!parsed.success) {
    problem(res, 422, "Ungültige Eingabe", {
      errors: parsed.error.issues.map(issue => ({
        field: issue.path.join("."),
        message: issue.message,
      })),
    });
    return;
  }

  if (await db.customers.findByEmail(parsed.data.email)) {
    problem(res, 409, "Kunde mit dieser E-Mail existiert bereits");
    return;
  }

  const customer = await db.customers.create(parsed.data);
  res.status(201).location(`/customers/${customer.id}`).json(customer);
});

const onError: ErrorRequestHandler = (err, req, res, next) => {
  if (err.type === "entity.parse.failed") {
    problem(res, 400, "Ungültiges JSON");
    return;
  }
  console.error(err);
  problem(res, 500, "Interner Fehler");
};
app.use(onError);

Das db-Objekt steht hier für Ihre jeweilige Datenbankschicht. Bei echtem Mehrbenutzerbetrieb sollte zusätzlich ein Unique-Index auf der E-Mail-Spalte liegen, denn die Prüfung auf Duplikate allein schützt nicht vor zwei gleichzeitigen Anfragen.

Dasselbe als Next.js Route Handler

Liegt Ihre API in einer Next.js-App, sieht der Endpunkt in app/api/customers/route.ts fast genauso aus:

import { CreateCustomer } from "@/lib/schemas";
import { db } from "@/lib/db";

export async function POST(request: Request) {
  const body = await request.json().catch(() => null);
  const parsed = CreateCustomer.safeParse(body);
  if (!parsed.success) {
    return Response.json(
      { title: "Ungültige Eingabe", status: 422, errors: parsed.error.issues },
      { status: 422, headers: { "Content-Type": "application/problem+json" } },
    );
  }
  // Duplikatprüfung wie oben (409)
  const customer = await db.customers.create(parsed.data);
  return Response.json(customer, { status: 201 });
}

Fehler einheitlich melden

Für Clients ist nichts lästiger als Fehler, die mal als Text, mal als HTML-Seite und mal als JSON mit wechselnden Feldnamen kommen. Legen Sie ein Format fest und halten Sie es überall ein. Ein verbreiteter Standard ist „Problem Details for HTTP APIs" nach RFC 9457, mit den Feldern type, title, status und optional detail, ergänzt um eigene Felder wie errors.

Die wichtigsten Statuscodes für einen schreibenden Endpunkt:

Code Bedeutung Wann
201 Created Datensatz angelegt; Location-Header zeigt auf die neue Ressource
400 Bad Request Anfrage nicht lesbar, etwa kaputtes JSON
401 Unauthorized Keine oder ungültige Authentifizierung
403 Forbidden Authentifiziert, aber keine Berechtigung
409 Conflict Widerspruch zum aktuellen Zustand, etwa ein Duplikat
422 Unprocessable Content JSON lesbar, aber inhaltlich ungültig
500 Internal Server Error Unerwarteter Fehler auf Ihrer Seite

Ob Sie für Validierungsfehler 400 oder 422 verwenden, ist eine Konvention. Wichtig ist, dass Sie sich festlegen. Und: Interne Details wie Stacktraces oder SQL-Meldungen gehören ins Server-Log, nie in die Antwort.

Dokumentieren mit OpenAPI

Eine API ohne Dokumentation wird über Rückfragen benutzt. Der übliche Standard ist OpenAPI. Für den Endpunkt oben genügt ein überschaubarer Abschnitt:

openapi: 3.1.0
info:
  title: Kunden-API
  version: 1.0.0
paths:
  /customers:
    post:
      summary: Kunden anlegen
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, email, country]
              properties:
                name: { type: string, maxLength: 200 }
                email: { type: string, format: email }
                country: { type: string, enum: [DE, AT, CH] }
                vatId: { type: string }
      responses:
        "201": { description: Kunde angelegt }
        "409": { description: E-Mail existiert bereits }
        "422": { description: Ungültige Eingabe }

Werkzeuge wie Swagger UI oder Redoc machen daraus eine lesbare Seite. Es gibt auch Bibliotheken, die OpenAPI direkt aus den zod-Schemas erzeugen; dann können Validierung und Dokumentation nicht auseinanderlaufen.

Tests, die den Vertrag absichern

Für jeden Endpunkt lohnen sich mindestens diese Tests:

  • Gültige Eingabe ergibt 201 und den angelegten Datensatz.
  • Fehlendes Pflichtfeld ergibt 422 mit dem Feldnamen in errors.
  • Unbekannter Ländercode ergibt 422.
  • Doppelte E-Mail ergibt 409.
  • Kaputtes JSON ergibt 400, nicht 500.
  • Ohne Authentifizierung ergibt 401.

Mit supertest lassen sich solche Tests in Express ohne laufenden Server schreiben. Sie sind gleichzeitig eine ausführbare Beschreibung dessen, was der Endpunkt verspricht.

Bevor der Endpunkt live geht

  • Authentifizierung und Berechtigungen geprüft
  • Rate Limiting, wenn der Endpunkt öffentlich erreichbar ist
  • Logging ohne personenbezogene Daten im Klartext
  • Keine Zugangsdaten im Code, sondern Umgebungsvariablen
  • Dokumentation an die Stelle gelegt, an der Ihre Partner sie finden

Häufige Fragen

Soll ich für Validierungsfehler 400 oder 422 verwenden?

Beides ist verbreitet; es ist eine Konvention. Häufig steht 400 für eine nicht lesbare Anfrage, etwa kaputtes JSON, und 422 für lesbare, aber inhaltlich ungültige Daten. Wichtig ist, dass Sie sich festlegen und das Format überall einhalten.

Warum reichen TypeScript-Typen für die Validierung nicht aus?

TypeScript-Typen existieren nur beim Kompilieren, zur Laufzeit sind sie verschwunden. Was ein Partner tatsächlich schickt, prüfen sie also nicht. Eine Bibliothek wie zod prüft die Eingabe beim Aufruf und erzeugt aus demselben Schema den TypeScript-Typ.

Wie dokumentiere ich einen REST-Endpunkt?

Der übliche Standard ist OpenAPI: Eine YAML- oder JSON-Datei beschreibt Pfade, Eingabefelder und mögliche Antworten. Werkzeuge wie Swagger UI oder Redoc machen daraus eine lesbare Seite. Wird die Beschreibung aus den zod-Schemas erzeugt, können Validierung und Dokumentation nicht auseinanderlaufen.


Bis zu zwei Endpunkte in Ihrer bestehenden API, mit Eingabevalidierung, eindeutigen Fehlercodes und Dokumentation (OpenAPI oder README), sind eine typische Aufgabe im Prozess-Abo. Sie beschreiben den Vertrag auf einer Karte: Ressource, Felder, Fehlerfälle und Authentifizierung, wie im Abschnitt oben. Zugang geben Sie per Einladung in Ihr Repository. Aus Ihrer Beschreibung werden die Abnahmekriterien, die ab dem Start fest auf der Karte stehen. 48 Stunden nach Start (gezählt Mo 8 – Fr 16 Uhr) erhalten Sie einen Pull Request auf einem eigenen Branch. Ein Senior-Entwickler hat ihn geprüft und freigegeben, und über den Merge entscheiden Sie. Die erste Aufgabe erledigen wir kostenlos, eine pro Unternehmen. Mehr dazu unter Programmierung und im Leistungskatalog.

Nächster Schritt
  • Die erste Aufgabe ist kostenlos – eine pro Unternehmen. Sie legen einen Workspace an, geben Zugang per Einladung und schreiben die Aufgabe auf Ihr Board. Wir liefern sie 48 Stunden nach Start, gezählt Mo 8 bis Fr 16 Uhr: Start am Montag um 10 Uhr – Lieferung am Mittwoch um 10 Uhr. Jede Lieferung prüft und gibt ein Senior-Entwickler frei, bevor sie zu Ihnen geht.

  • Danach im Prozess-Abo: Sie schreiben Aufgaben aufs Board und bestimmen die Reihenfolge, wir setzen sie um. Prozess-Abo 1: 1.000 € im Monat, 1 Aufgabe gleichzeitig in Arbeit, bis zu 1 Aufgaben pro Monat; Prozess-Abo 2,5: 2.500 € im Monat, 1 Aufgabe gleichzeitig in Arbeit, bis zu 4 Aufgaben pro Monat; Prozess-Abo 5: 5.000 € im Monat, 1 Aufgabe gleichzeitig in Arbeit; Prozess-Abo 10: 10.000 € im Monat, 2 Aufgaben parallel in Arbeit. Gezählt werden die Aufgaben, die wir im Abrechnungsmonat beginnen; eine Nachbesserung zählt nicht neu. Nicht genutzte Aufgaben verfallen am Monatsende. Preise zzgl. MwSt., nur für Unternehmen, monatlich kündbar – das Abo beginnt nur, wenn Sie es bestellen.

  • Fragen vorab oder mehr Aufgaben parallel? Im kostenlosen Projekt-Check (30 Minuten) klären wir das, auch für ein individuelles Abo.