MovieVault Walkthrough: Eine Watchlist-API mit Express 5, Prisma 7 und JWT
MovieVault – eine zeitbasierte Full-Stack-Übung: Eine Watchlist-API mit Express 5, Prisma 7 und JWT, inklusive Anmerkungen zu Eigentumsprüfungen, Kaskaden und Fehlerbehandlung.
Quellen: PRISMA SCHEMA SIMPLE TOUCH (BACKEND)
Zeitgesteuerte Live-Coding-Übungen sind eine gängige Methode, um Full-Stack-Entwickler zu bewerten, und belohnen Kandidaten, die in der Lage sind, eine kurze Spezifikation schnell in eine funktionierende, sichere API umzuwandeln. In dieser Anleitung wird eine solche kurze Spezifikation verwendet – ein Filmkatalog namens MovieVault mit einer persönlichen Wunschliste – und sie mit einem vollständigen Backend aus Express, TypeScript und Prisma kombiniert. Sie werden sehen, wie das Datenmodell eine viele-zu-vielen-Beziehung darstellt, wie Authentifizierungs- und Eigentumsregeln in Middleware- sowie Controller-Prüfungen umgesetzt werden, und welche Unzulänglichkeiten in einer zeitbegrenzten Lösung vor dem Einsatz in der Produktion behoben werden sollten.
Die Spezifikation für MovieVault
Die Anwendung ermöglicht es Benutzern, durch einen Filmkatalog zu blättern, Titel in eine persönliche Wunschliste zu speichern und beides zu verwalten. Die erforderlichen Funktionen sind:
- Registrierung und Anmeldung.
Der Server verwendet Express, TypeScript und Prisma; der Client nutzt React, Tailwind und ShadCN UI.
Grundregeln
- Die Zeitbeschränkung beträgt 280 Minuten.
- Die Arbeit muss eigene Arbeit sein.
node_modulesmuss über.gitignoreausgeschlossen werden, und eine.env.example-Datei muss eingereicht werden.- ShadCN UI ist für Kernkomponenten wie Button, Card und Input zwingend erforderlich.
- Fehler müssen auf dem Server behandelt werden und mindestens über
console.logoder eine Benachrichtigung dem Benutzer auf der Client-Seite angezeigt werden.
Einrichtung des Servers und des Datenmodells
Der Server ist ein Express-Projekt, das in TypeScript geschrieben wurde und Prisma als ORM verwendet. In der Anfrage werden drei Modelle gefordert: User (eine id, die entweder eine Ganzzahl oder ein String sein kann, eine eindeutige email, password und name), Movie (id, title, genre, description, imageUrl) sowie ein Modell für die Wunschliste (id, userId, movieId, ein review-String, der standardmäßig leer ist, sowie eine Ganzzahl rating, die standardmäßig 1 beträgt). Benutzer und Filme stehen über die Tabelle der Wunschliste in einer many-to-many-Beziehung zueinander, und mindestens fünf Filme müssen aus einer manuell erstellten JSON-Datei unter server/data/movies.json eingefügt werden.
Das untenstehende Schema implementiert dies als explizites Join-Modell. Zwei Aspekte verdienen besondere Aufmerksamkeit. Die Einschränkung @@unique([userId, movieId]) bedeutet, dass ein Benutzer denselben Film nicht zweimal speichern kann, und sie gibt Prisma außerdem eine zusammengesetzte Schlüsselstruktur mit dem Namen userId_movieId zur Verfügung, die die Controller später für Abfragen nutzen. Der datasource-Block enthält keinen url: In Prisma 7 wird die Verbindungszeichenkette außerhalb des Schemas (in prisma.config.ts) konfiguriert, und der Client verbindet sich über einen Adapter – deshalb erscheinen @prisma/adapter-pg sowie pg in den Abhängigkeiten. Für die vollständigen Konfigurationsschritte siehe die Einrichtung von Prisma 7 mit PostgreSQL in einem TypeScript Node.js-Projekt.
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
}
model User {
id Int @id @default(autoincrement())
email String @unique
name String
password String
watchList WatchList[]
}
model Movie {
id Int @id @default(autoincrement())
title String
genre String
description String
imageUrl String
watchList WatchList[]
}
model WatchList{
id Int @id @default(autoincrement())
review String @default("")
rating Int @default(1)
userId Int
movieId Int
user User @relation(fields: [userId], references: [id] )
movie Movie @relation(fields: [movieId], references: [id] )
@@unique([userId, movieId])
}
Einrichtung des Clients
Die Frontend-Plattform ist eine React-Anwendung, die mit Vite aufgebaut wurde, mit Tailwind CSS und ShadCN UI gestylt sowie mit React Router geroutet ist. Die Spezifikation verknüpft jeden Server-Meilenstein mit einem Client-Bildschirm:
/loginund/register-Seiten, die aus ShadCN-Formkomponenten erstellt wurden. Nach einem erfolgreichen Anmelden wird das Token gespeichert und der Benutzer auf das Dashboard bei/umgeleitet.- Ein Dashboard, das Filme als Gitter aus ShadCN-Karten anzeigt. Jede Karte verfügt über einen „Zur Wunschliste hinzufügen“- und einen „Film löschen“-Button, wobei das Löschen sofort das Gitter aktualisieren muss.
- Eine
/my-watchlist-Seite, die die vom Benutzer gespeicherten Filme zusammen mit derRezensionundBewertungjeder Eintragung auflistet. - Ein Bearbeitungsprozess für Einträge in der Wunschliste – entweder über ein ShadCN-Dialogfeld oder eine separate Seite – wobei die Benutzeroberfläche sofort aktualisiert wird, sobald die Änderung erfolgreich ist.
Das API-Vertragswerk
POST /registerregistriert einen Benutzer; es erfolgt keine Authentifizierung.POST /logingibt ein JWT-Zugriffstoken zurück; es erfolgt keine Authentifizierung.GET /movieslistet alle Filme auf; Authentifizierung erforderlich.DELETE /movies/:identfernt einen Film aus dem Katalog; Authentifizierung erforderlich.GET /watchlistlistet die Einträge des aktuellen Benutzers auf und verwendet Prismasinclude, um Filmdetails anzuhängen; Authentifizierung erforderlich.POST /watchlist/:movieIdfügt einen Film zur Watchlist des aktuellen Benutzers hinzu; Authentifizierung erforderlich.PATCH /watchlist/:idaktualisiert Rezensionen und Bewertungen ohne Überprüfung des Eigentümers; Authentifizierung erforderlich.DELETE /watchlist/:identfernt einen Eintrag; Authentifizierung erforderlich und nur der Eigentümer darf dies tun.
Passwörter müssen mit bcrypt gehässt werden.
Anmeldung und Login
Der Auth-Controller kümmert sich um beide öffentlichen Endpunkte. register lehnt eine Anfrage mit 400 ab, wenn ein Feld fehlt, hasht das Passwort mit einem bcrypt-Salz der Stärke 12, erstellt den Benutzer und signiert sofort ein JWT, das die id, name und email des Benutzers enthält und nach einem Tag abläuft. login sucht den Benutzer über die E-Mail, vergleicht das Passwort mit bcrypt.compare und gibt dasselbe Token zurück. Beide Handler geben nur sichere Benutzerfelder aus, niemals den Hash.
import { Request, Response } from "express";
import prisma from "../libs/prisma";
import bcrypt from "bcrypt";
import jwt from "jsonwebtoken";
export const register = async (req: Request, res: Response) => {
try {
const { name, email, password } = req.body;
if (!name || !email || !password) {
return res.status(400).json({
message: "Semua kolom harus diisi",
});
}
const salt = await bcrypt.genSalt(12);
const hashedPassword = await bcrypt.hash(password, salt);
const newUser = await prisma.user.create({
data: {
name: name,
email: email,
password: hashedPassword,
},
});
const token = jwt.sign(
{
id: newUser.id,
name: newUser.name,
email: newUser.email,
},
process.env.JWT_SECRET as string,
{ expiresIn: "1d" },
);
return res.status(201).json({
message: "User Berhasil di buat",
token: token,
data: {
id: newUser.id,
name: newUser.name,
email: newUser.email,
},
});
} catch (error) {
return res.status(500).json({
message: "gagal membuat user",
error: error,
});
}
};
export const login = async (req: Request, res: Response) => {
try {
const { email, password } = req.body;
const user = await prisma.user.findUnique({
where: { email: email },
});
if (!user) {
return res.status(400).json({
message: "Email/Username/Password salah",
});
}
const isPasswordValid = user
? await bcrypt.compare(password, user.password)
: false;
if (!isPasswordValid) {
return res.status(400).json({
message: "Email/Username/Password salah",
});
}
const token = jwt.sign(
{
id: user.id,
name: user.name,
email: user.email,
},
process.env.JWT_SECRET as string,
{ expiresIn: "1d" },
);
return res.status(200).json({
message: "Login Berhasil",
token: token,
data: {
id: user.id,
email: user.email,
name: user.name,
},
});
} catch (error) {
return res.status(500).json({
message: "Terjadi kesalahan pada server",
error: error,
});
}
};
Aspekte, die verbessert werden sollten:
- Beim Login wird für eine unbekannte E-Mail sowie ein falsches Passwort dieselbe Meldung verwendet, was verhindert, dass bekannt wird, welche Konten existieren. Die Rückgabe von 401 anstelle von 400 würde den Fehler genauer beschreiben.
error-Objekt an den Client. Protokollieren Sie es auf dem Server und geben Sie nur eine Nachricht zurück.process.env.JWT_SECRET als String schweigt den Compiler, nicht jedoch die Laufzeit; fehlt die Variable, scheitert die Signierung bei jeder Anfrage. Überprüfen Sie die erforderlichen Umgebungsvariablen beim Start.Für eine verbesserte Version dieses Ablaufs siehe JWT-Authentifizierung für die Produktion in Node.js-APIs.
Das Authentifizierungs-Middleware
Das Middleware liest den Authorization-Header, nimmt den Teil nach dem ersten Leerzeichen als Token und gibt 401 zurück, wenn dieses fehlt. Anschließend überprüft es das Token mit dem gemeinsamen Geheimnis und fügt die decodierte Nutzlast der Anfrage hinzu, damit Controller die id-Werte des Benutzers lesen können. Ein ungültiges oder abgelaufenes Token führt ebenfalls zu 401.
import { Request, Response, NextFunction } from "express";
import jwt from "jsonwebtoken";
export const authentication = (
req: Request,
res: Response,
next: NextFunction,
) => {
const authHeader = req.headers.authorization;
const token = authHeader && authHeader.split(" ")[1];
if (!token) {
return res.status(401).json({ message: "No token provided" });
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET as string);
(req as any).user = decoded;
next();
} catch (error) {
return res.status(401).json({ message: "Invalid token" });
}
};
Die Typumwandlung (req as any).user funktioniert zwar, vernachlässigt aber die Typüberprüfung in jedem Controller, der sie verwendet. Durch das Erweitern der Request-Schnittstelle von Express mittels Declaration Merging bleibt req.user typisiert. Die Analyse des Headers akzeptiert außerdem jedes Schema, nicht nur Bearer.
Projektabhängigkeiten
Das package.json fixiert den Stack zum Zeitpunkt der Erstellung: Express 5, Prisma 7 mit dem PostgreSQL-Adapter, bcrypt, jsonwebtoken und cors, sowie TypeScript und ts-node-dev für ein dev-Skript, das bei Änderungen neu gestartet wird. Versionsspannen ändern sich schnell, daher sollten Sie vor der Wiederverwendung die aktuellen Veröffentlichungen überprüfen.
{
"name": "server",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
"build": "tsc"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
"bcrypt": "^6.0.0",
"cors": "^2.8.6",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3",
"pg": "^8.22.0"
},
"devDependencies": {
"@types/bcrypt": "^6.0.0",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^26.0.1",
"prisma": "^7.8.0",
"ts-node-dev": "^2.0.0",
"typescript": "^6.0.3"
}
}
Beachten Sie, dass nichts in der Liste eine .env-Datei in process.env lädt, sodass Sie etwas wie dotenv oder die Unterstützung für Env-Dateien Ihres Laufzeitumfelds benötigen, damit JWT_SECRET verfügbar ist.
Film-Endpunkte
getMovies gibt einfach alle Filme zurück. deleteMovie analysiert den Route-Parameter, gibt 400 für eine nicht numerische ID sowie 404 für eine unbekannte ID aus und führt anschließend eine $transaction durch, die zunächst alle Zeilen der Wunschliste löscht, die auf den Film verweisen, und danach den Film selbst. Das Vorher-Löschen der Kinddaten verhindert einen Foreign-Key-Verstoß, und die Transaktion sorgt dafür, dass beide Schritte entweder gemeinsam erfolgreich oder gemeinsam fehlschlagen.
import { Request, Response } from "express";
import prisma from "../libs/prisma";
// GET /movies - Mendapatkan semua daftar film
export const getMovies = async (req: Request, res: Response) => {
try {
const movies = await prisma.movie.findMany();
return res.status(200).json(movies);
} catch (error) {
console.error("Get Movies Error:", error);
return res.status(500).json({
message: "Terjadi kesalahan pada server saat mengambil data film",
});
}
};
// DELETE /movies/:id - Menghapus film dari katalog utama
export const deleteMovie = async (req: Request, res: Response) => {
try {
const { id } = req.params;
const movieId = parseInt(id, 10);
if (isNaN(movieId)) {
return res.status(400).json({ message: "ID film tidak valid" });
}
// Periksa apakah film ada
const movie = await prisma.movie.findUnique({
where: { id: movieId },
});
if (!movie) {
return res.status(404).json({ message: "Film tidak ditemukan" });
}
// Menggunakan Transaction untuk menghapus watchlist terkait terlebih dahulu
// guna menghindari pelanggaran batasan foreign key (kunci asing)
await prisma.$transaction([
prisma.watchList.deleteMany({
where: { movieId: movieId },
}),
prisma.movie.delete({
where: { id: movieId },
}),
]);
return res.status(200).json({
message: "Film berhasil dihapus dari katalog utama",
});
} catch (error) {
console.error("Delete Movie Error:", error);
return res.status(500).json({
message: "Terjadi kesalahan pada server saat menghapus film",
});
}
};
Eine Alternative besteht darin, auf der movie-Beziehung der Wunschliste onDelete: Cascade zu deklarieren, wodurch die Datenbank die abhängigen Zeilen automatisch entfernt. Die explizite Transaktion ist im Code besser sichtbar; das Cascade-Verhalten ist hingegen schwieriger zu übersehen.
Endpunkte der Wunschliste
Der Controller der Wunschliste enthält die interessantesten Regeln:
getWatchlistfiltert nach der Benutzer-ID aus dem Token und verwendetinclude: { movie: true }, sodass jede Eintragung zusammen mit ihrem Film übergeben wird.addToWatchlistprüft die Film-ID, stellt sicher, dass der Film existiert, sucht nach dem KombinationsschlüsseluserId_movieId, um Duplikate mit einer 400-Fehlermeldung abzulehnen, und erstellt die Eintragung mit der Standardbewertung und -bewertung.updateWatchlistimplementiert die gemeinsame Regel: Es prüft, ob die Eintragung existiert, aber nicht, wer sie besitzt. Falls eineratingvorliegt, muss diese in eine Ganzzahl von 1 bis 5 umgewandelt werden. Von Prisma alsundefinedhinterlegte Felder werden ignoriert, sodass eine teilweise Aktualisierung nur die übermittelten Daten betrifft.deleteFromWatchlistlädt die Eintragung und gibt 403 zurück, wenn ihreuserIdvon der des Aufrufers abweicht – dadurch wird die Regel, dass nur der Eigentümer löschen darf, durchgesetzt.
import { Request, Response } from "express";
import prisma from "../libs/prisma";
// GET /watchlist - Mengambil semua watchlist milik user yang sedang login
export const getWatchlist = async (req: Request, res: Response) => {
try {
const userId = (req as any).user.id;
const watchlist = await prisma.watchList.findMany({
where: { userId: userId },
include: {
movie: true, // Menyertakan informasi film
},
});
return res.status(200).json(watchlist);
} catch (error) {
console.error("Get Watchlist Error:", error);
return res.status(500).json({
message: "Terjadi kesalahan pada server saat mengambil watchlist",
});
}
};
// POST /watchlist/:movieId - Menambahkan film ke watchlist user yang login
export const addToWatchlist = async (req: Request, res: Response) => {
try {
const { movieId } = req.params;
const userId = (req as any).user.id;
const parsedMovieId = parseInt(movieId, 10);
if (isNaN(parsedMovieId)) {
return res.status(400).json({ message: "ID film tidak valid" });
}
// Periksa apakah film tersebut ada di katalog
const movie = await prisma.movie.findUnique({
where: { id: parsedMovieId },
});
if (!movie) {
return res.status(404).json({ message: "Film tidak ditemukan" });
}
// Periksa apakah film sudah ada di watchlist user tersebut
const existingWatchlist = await prisma.watchList.findUnique({
where: {
userId_movieId: {
userId: userId,
movieId: parsedMovieId,
},
},
});
if (existingWatchlist) {
return res.status(400).json({
message: "Film ini sudah ada di dalam daftar watchlist Anda",
});
}
const newWatchlistItem = await prisma.watchList.create({
data: {
userId: userId,
movieId: parsedMovieId,
review: "",
rating: 1,
},
include: {
movie: true,
},
});
return res.status(201).json({
message: "Film berhasil ditambahkan ke watchlist",
data: newWatchlistItem,
});
} catch (error) {
console.error("Add to Watchlist Error:", error);
return res.status(500).json({
message: "Terjadi kesalahan pada server saat menambahkan watchlist",
});
}
};
// PATCH /watchlist/:id - Memperbarui review dan rating (siapa saja yang login bisa mengubah)
export const updateWatchlist = async (req: Request, res: Response) => {
try {
const { id } = req.params;
const { review, rating } = req.body;
const watchlistId = parseInt(id, 10);
if (isNaN(watchlistId)) {
return res.status(400).json({ message: "ID watchlist tidak valid" });
}
// Periksa apakah item watchlist ada
const watchlistItem = await prisma.watchList.findUnique({
where: { id: watchlistId },
});
if (!watchlistItem) {
return res.status(404).json({ message: "Item watchlist tidak ditemukan" });
}
// Validasi rating jika dikirimkan
if (rating !== undefined) {
const parsedRating = parseInt(rating, 10);
if (isNaN(parsedRating) || parsedRating < 1 || parsedRating > 5) {
return res.status(400).json({
message: "Rating harus berupa angka antara 1 sampai 5",
});
}
}
const updatedItem = await prisma.watchList.update({
where: { id: watchlistId },
data: {
review: review !== undefined ? review : undefined,
rating: rating !== undefined ? parseInt(rating, 10) : undefined,
},
include: {
movie: true,
},
});
return res.status(200).json({
message: "Watchlist berhasil diperbarui",
data: updatedItem,
});
} catch (error) {
console.error("Update Watchlist Error:", error);
return res.status(500).json({
message: "Terjadi kesalahan pada server saat memperbarui watchlist",
});
}
};
// DELETE /watchlist/:id - Menghapus film dari watchlist (hanya bisa oleh pembuatnya/Owner)
export const deleteFromWatchlist = async (req: Request, res: Response) => {
try {
const { id } = req.params;
const userId = (req as any).user.id;
const watchlistId = parseInt(id, 10);
if (isNaN(watchlistId)) {
return res.status(400).json({ message: "ID watchlist tidak valid" });
}
// Periksa apakah item watchlist ada
const watchlistItem = await prisma.watchList.findUnique({
where: { id: watchlistId },
});
if (!watchlistItem) {
return res.status(404).json({ message: "Item watchlist tidak ditemukan" });
}
// Validasi kepemilikan (Owner)
if (watchlistItem.userId !== userId) {
return res.status(403).json({
message: "Anda tidak memiliki akses untuk menghapus item ini dari watchlist",
});
}
await prisma.watchList.delete({
where: { id: watchlistId },
});
return res.status(200).json({
message: "Film berhasil dihapus dari watchlist Anda",
});
} catch (error) {
console.error("Delete Watchlist Error:", error);
return res.status(500).json({
message: "Terjadi kesalahan pada server saat menghapus watchlist",
});
}
};
Zwei Randfälle, die man kennen sollte. Die Prüfung auf Duplikate und das Einfügen sind getrennte Abfragen, sodass zwei gleichzeitige Anfragen beide die Prüfung bestehen können; die eindeutige Beschränkung schützt die Daten dennoch, doch der Verlierer erhält eine 500-Fehlermeldung, es sei denn, man erkennt die Verletzung. Zudem ist parseInt nachsichtig: "4.7" wird zu 4 und "3abc" zu 3, weshalb eine strengere Prüfung wie Number.isInteger(Number(rating)) fehlerhafte Eingaben zuverlässiger ablehnt.
Verbindung der Routen
Die Routing-Schicht enthält eine Datei pro Ressource sowie einen Index. Authentifizierungs-Endpunkte sind unter /auth aufgebaut, während /movies und /watchlist zum Zeitpunkt der Einrichtung durch das authentication-Middleware geleitet werden, sodass jeder dahinterliegende Handler ohne wiederholte Überprüfung geschützt ist. Der Einstiegspunkt aktiviert CORS, parsert JSON-Körper, montiert alles unter /api und lauscht auf Port 3000.
authRoute.ts
import { Router } from "express";
import { login, register } from "../controllers/authController";
const router = Router();
router.post("/register", register);
router.post("/login", login);
export default router;
index.ts
import { Router } from "express";
import authRoute from "./authRoute";
import movieRoute from "./movieRoute";
import watchlistRoute from "./watchlistRoute";
import { authentication } from "../middlewares/authMiddleware";
const route = Router();
route.use("/auth", authRoute);
route.use("/movies", authentication, movieRoute);
route.use("/watchlist", authentication, watchlistRoute);
export default route;
movieRoute.ts
import { Router } from "express";
import { deleteMovie, getMovies } from "../controllers/movieController";
const router = Router();
router.get("/", getMovies);
router.delete("/:id", deleteMovie);
export default router;
watchlistRoute.ts
import { Router } from "express";
import {
getWatchlist,
addToWatchlist,
updateWatchlist,
deleteFromWatchlist,
} from "../controllers/watchlistController";
const router = Router();
router.get("/", getWatchlist);
router.post("/:movieId", addToWatchlist);
router.patch("/:id", updateWatchlist);
router.delete("/:id", deleteFromWatchlist);
export default router;
isi main index.ts yang selevel dengan src:
import express from "express";
import route from "./routes";
import cors from "cors";
const app = express();
const port = 3000;
app.use(cors());
app.use(express.json());
app.use("/api", route);
app.listen(port, () => {
console.log(`Server is running on port ${port}`);
});
Wegen dieser Präfixe sind die tatsächlichen Pfade /api/auth/register, /api/movies usw., und nicht die einfachen Pfade aus dem Vertrag. Stellen Sie sicher, dass der Client sowie alle API-Dokumentationen dieselbe Basis-URL verwenden.
Haupterkenntnisse
- Ein explizites Verknüpfungsmodell mit einer zusammengesetzten eindeutigen Beschränkung bietet sowohl Schutz vor Duplikaten als auch eine praktische Suchschlüssel.
req.user sowie die Überprüfung der Umgebungsvariablen beim Starten.