mitmario.dev

Abschlussprojekt

Node.js Sandbox 4 Min Lesezeit 3 BeispieleLektion 6 von 7

Du baust eine Ausleihverwaltung. Bücher, Nutzer, Ausleihen. Eine öffentliche Suche, eine Anmeldung, und geschützte Routen zum Ausleihen. Das ist keine Übung mehr, sondern eine kleine Anwendung, wie sie in echt aussieht.

Was am Ende steht

RouteWer darfWas passiert
GET /api/buecher?q=jederSuche im Bestand, mit Verfügbarkeit je Buch
GET /api/buecher/:idjederein Buch, oder 404
POST /loginjederAnmeldung, Sitzung, 303
POST /api/ausleihennur angemeldetleiht aus, oder 401, 404, 409

Vier Routen, und in ihnen steckt so ziemlich alles aus achtzehn Abschnitten: Module, Dateisystem, Umgebung, npm, asynchroner Code, HTTP, Express, Middleware, JSON, SQLite, Sitzungen, Anmeldung, Sicherheit, Fehlerbehandlung und Struktur.

Etappe 1: das Datenmodell

Das Datenmodell und die eine schwierige Frage
import { DatabaseSync } from "node:sqlite";

const db = new DatabaseSync(":memory:");

db.exec(`CREATE TABLE buecher (id INTEGER PRIMARY KEY, titel TEXT NOT NULL, jahr INTEGER)`);
db.exec(`CREATE TABLE nutzer (id INTEGER PRIMARY KEY, email TEXT NOT NULL UNIQUE, passwort TEXT NOT NULL)`);
db.exec(`CREATE TABLE ausleihen (
  id INTEGER PRIMARY KEY,
  buch_id INTEGER NOT NULL REFERENCES buecher(id),
  nutzer_id INTEGER NOT NULL REFERENCES nutzer(id),
  geliehen_am TEXT NOT NULL,
  zurueck_am TEXT
)`);

db.exec(`INSERT INTO buecher (titel, jahr) VALUES ('HTTP verstehen', 2017), ('Nachtschicht mit Node', 2023)`);
db.exec(`INSERT INTO nutzer (email, passwort) VALUES ('test@beispiel.de', 'scrypt$...')`);
db.exec(`INSERT INTO ausleihen (buch_id, nutzer_id, geliehen_am) VALUES (1, 1, '2026-08-01')`);
db.exec(`INSERT INTO ausleihen (buch_id, nutzer_id, geliehen_am, zurueck_am) VALUES (2, 1, '2026-07-01', '2026-07-20')`);

// Es gibt keine Spalte "verfuegbar", und das ist Absicht: Sie waere
// eine zweite Wahrheit neben den Ausleihen und liefe auseinander,
// sobald irgendwo ein Update fehlt.
const zeilen = db.prepare(`
  SELECT b.id, b.titel,
         (SELECT count(*) FROM ausleihen a WHERE a.buch_id = b.id AND a.zurueck_am IS NULL) AS offen
  FROM buecher b ORDER BY b.id
`).all();

for (const zeile of zeilen) {
  console.log(`${zeile.id} ${zeile.titel.padEnd(24)} offen: ${zeile.offen} verfuegbar: ${zeile.offen === 0}`);
}

Drei Tabellen, und die interessante Entscheidung ist eine, die man leicht falsch trifft: es gibt keine Spalte verfuegbar. Sie wäre bequem und wäre eine zweite Wahrheit neben den Ausleihen. Beim ersten vergessenen Update stünde dort etwas anderes, als die Ausleihen sagen, und dann glaubt niemand mehr einer von beiden.

Stattdessen wird gefragt: Gibt es zu diesem Buch eine Ausleihe ohne Rückgabedatum? Genau das macht die Unterabfrage im Beispiel, in einer einzigen Anweisung für alle Bücher auf einmal.

Etappe 2: die Mitte, und warum sie zuerst kommt

Die Logik, ohne Server aufgerufen
import { DatabaseSync } from "node:sqlite";

class NichtGefunden extends Error { status = 404; code = "nicht_gefunden"; }
class Konflikt extends Error { status = 409; code = "konflikt"; }

const db = new DatabaseSync(":memory:");
db.exec(`CREATE TABLE buecher (id INTEGER PRIMARY KEY, titel TEXT NOT NULL)`);
db.exec(`CREATE TABLE ausleihen (id INTEGER PRIMARY KEY, buch_id INTEGER, nutzer_id INTEGER, zurueck_am TEXT)`);
db.exec(`INSERT INTO buecher (titel) VALUES ('HTTP verstehen')`);

function leiheAus(nutzerId, buchId) {
  const buch = db.prepare("SELECT id, titel FROM buecher WHERE id = ?").get(buchId);
  if (!buch) throw new NichtGefunden(`Ein Buch mit der Kennung ${buchId} gibt es nicht.`);

  const offen = db
    .prepare("SELECT count(*) AS anzahl FROM ausleihen WHERE buch_id = ? AND zurueck_am IS NULL")
    .get(buchId).anzahl;

  if (offen > 0) throw new Konflikt(`„${buch.titel}“ ist gerade verliehen.`);

  db.prepare("INSERT INTO ausleihen (buch_id, nutzer_id) VALUES (?, ?)").run(buchId, nutzerId);

  return { buchId, titel: buch.titel };
}

// Kein Server, kein curl, kein Port: Die Mitte laesst sich aufrufen.
for (const [nutzer, buch] of [[1, 1], [1, 1], [1, 99]]) {
  try {
    console.log("geklappt:", JSON.stringify(leiheAus(nutzer, buch)));
  } catch (fehler) {
    console.log(`${fehler.constructor.name} (${fehler.status}):`, fehler.message);
  }
}

Die Regeln der Anwendung sind schnell aufgezählt: Ein Buch, das es nicht gibt, kann man nicht ausleihen. Ein Buch, das schon verliehen ist, auch nicht. Alles andere darf.

Diese drei Sätze stehen in der mittleren Schicht (Lektion 18.1), und du erkennst sie am Beispiel daran, dass kein Server läuft. Die Funktion wird einfach aufgerufen, dreimal, und antwortet mit einem Wert oder mit einem Fehler. Genau deshalb wäre sie auch die Schicht, die du testen würdest.

Zwei Feinheiten stecken darin:

  • Prüfen und Anlegen gehören in dieselbe Transaktion. Zwei gleichzeitige Anfragen sehen sonst beide „ist frei” und legen beide an. Derselbe Gedanke wie bei UNIQUE in Lektion 14.2.
  • Der Fehler trägt seinen Statuscode, nicht seine Route. Ein NichtGefunden weiß, dass es ein 404 ist, und das ist keine HTTP-Kenntnis in der Mitte, sondern eine Zahl, die oben abgelesen wird.

Etappe 3: die Routen

Die Route, die daraus HTTP macht
import { logge } from "./protokoll.js";

// Ganz unten in server.js, mit vier Argumenten. Eine Stelle fuer alle
// Fehler, und die Uebersetzung in HTTP passiert genau hier (16.3).
export function fehlerHandler(fehler, req, res, next) {
  const status = fehler.status ?? 500;

  logge(status >= 500 ? "error" : "warn", "anfrage_gescheitert", {
    pfad: req.originalUrl,
    fehler: fehler.message,
  });

  res.status(status).json({
    fehler: {
      code: fehler.code ?? "serverfehler",
      // Nach innen die ganze Wahrheit, nach aussen ein Satz.
      meldung: status === 500 ? "Da ist etwas schiefgegangen." : fehler.message,
    },
  });
}

Oben bleibt wenig zu tun, und das ist das Ziel: entgegennehmen, prüfen, weitergeben, antworten. Die Eingabeprüfung gehört hierher, denn hier ist die Systemgrenze (Lektion 15.1). Die Statuscodes 404 und 409 gehören nicht hierher, die stehen am Fehler.

Der Handler ganz unten macht daraus dann eine einheitliche Antwort, und zwar für alle Fehler auf einmal. Nach innen die vollständige Meldung, nach außen ein Satz und ein maschinenlesbarer Code. Wer in Abschnitt 16 aufgepasst hat, schreibt diesen Handler einmal und nie wieder.

Was ausdrücklich nicht dazugehört

Damit die Aufgabe machbar bleibt und nicht ausufert, ist dreierlei nicht verlangt: kein schönes Frontend (die API antwortet mit JSON und das reicht), keine E-Mails (Passwort vergessen, Bestätigung, Erinnerung an die Rückgabe), kein Deployment. Alle drei wären in einem echten Projekt dran, und alle drei brauchen etwas, das dieser Kurs nicht hat.

Auch die Rückgabe ist nicht verlangt. Sie wäre eine UPDATE-Anweisung auf zurueck_am und damit die naheliegendste Erweiterung, wenn du danach weitermachen willst.

Und die Tests?

Die gehören dazu, und sie sind hier trotzdem nicht Teil der Aufgabe. Der Grund ist technisch und ehrlich: Ein Schritt startet entweder deinen Server oder deine Tests, nicht beides, und geprüft wird hier über echte HTTP-Anfragen gegen den laufenden Server.

Abschnitt 17 war der Ort dafür, und die mittlere Schicht aus Etappe 2 ist genau die, die du ohne Server aufrufen kannst. Wenn du dieses Projekt nach dem Kurs weiterbaust, ist eine bibliothek.test.js mit fünf Fällen der erste Schritt, den ich machen würde: der Normalfall, das unbekannte Buch, das verliehene Buch, die leere Suche, die Suche mit Treffern.

Woran du merkst, dass du fertig bist

Nicht daran, dass es läuft. Sondern daran, dass du für jede der vier Routen sagen kannst, was sie antwortet, wenn etwas schiefgeht, und dass keine dieser Antworten einen Stacktrace enthält. Die Prüfliste in der Challenge fragt genau das ab.

Zum Mitnehmen

Dieser Kurs hat bewusst kein durchgehendes Projekt gebaut, sondern achtzehn Abschnitte lang Bausteine. Diese Lektion sammelt sie ein. Sie ist die längste des Kurses, und das ist keine Übertreibung.

Jetzt du

Basis Konto, kostenlos

Zu dieser Lektion gehört eine Aufgabe. Du schreibst den Code selbst, und nach jedem Lauf sagt dir eine Prüfliste, was schon stimmt.

Dafür brauchst du das Basis Konto. Es kostet nichts, und ein Passwort gibt es auch nicht.

In diesem Kurs läuft dein Code auf einem Server. Dafür hat das Basis Konto 1 Stunde im Monat, mehr Zeit gibt es mit dem Premium Konto.

Was in dieser Lektion steckt

  • Artikel mit 3 Beispielen zum Ausprobieren

    Steht hier, ohne Konto lesbar.

  • Aufgabe, dein Code läuft auf einem Server

    Öffnet sich mit dem Basis Konto.