Guide complet MovieVault : une API de liste de suivi avec Express 5, Prisma 7 et JWT
MovieVault, un exercice complet sur le développement full-stack avec des délais précis : une API de liste de suivi utilisant Express 5, Prisma 7 et JWT, accompagnée de notes d’analyse sur les vérifications de propriété, les cascades et la gestion des erreurs.
Sources: PRISMA SCHEMA SIMPLE TOUCH (BACKEND)
Les exercices de codage en direct chronométrés constituent une méthode courante pour évaluer les développeurs full-stack, et ils récompensent ceux qui parviennent à transformer rapidement une spécification succincte en une API fonctionnelle et sécurisée. Cette démarche utilise une telle spécification : un catalogue de films nommé MovieVault avec une liste de visionnage personnelle, associée à un backend complet basé sur Express, TypeScript et Prisma. Vous verrez comment le modèle de données exprime une relation many-to-many, comment les règles d’authentification et de propriété se traduisent en vérifications au niveau des middleware et des contrôleurs, ainsi quels défauts dans une solution à délai limité méritent d’être corrigés avant qu’elle ne soit mise en production.
La spécification de MovieVault
L’application permet aux utilisateurs de parcourir un catalogue de films, d’enregistrer des titres dans une liste de visionnage personnelle et de gérer les deux. Les fonctionnalités requises sont :
- Inscription et connexion.
Le serveur utilise Express, TypeScript et Prisma ; le client utilise React, Tailwind et ShadCN UI.
Règles de base
- La durée impérative est de 280 minutes.
- Le travail doit être original.
node_modulesdoit être exclu via.gitignore, et un fichier.env.exampledoit être commité.- ShadCN UI est obligatoire pour les composants de base tels que Button, Card et Input.
- Les erreurs doivent être gérées sur le serveur et affichées à l’utilisateur côté client, au minimum via
console.logou un message toast.
Mise en place du serveur et du modèle de données
Le serveur est un projet Express écrit en TypeScript, utilisant Prisma comme ORM. La demande prévoit trois modèles : User (un id pouvant être un entier ou une chaîne, un email unique, ainsi que des champs password et name), Movie (id, title, genre, description, imageUrl) et un modèle de liste de visionnage (id, userId, movieId, une chaîne review dont la valeur par défaut est une chaîne vide, et un entier rating dont la valeur par défaut est 1). Les utilisateurs et les films sont reliés de manière many-to-many via la table de liste de visionnage, et au moins cinq films doivent être importés à partir d’un fichier JSON manuellement rédigé situé à server/data/movies.json.
Le schéma ci-dessous met en œuvre cela via un modèle de jointure explicite. Deux détails méritent attention. La contrainte @@unique([userId, movieId]) signifie qu’un utilisateur ne peut pas enregistrer le même film deux fois, et elle fournit également à Prisma une clé composée nommée userId_movieId que les contrôleurs utilisent par la suite pour effectuer des recherches. Le bloc datasource ne contient pas de champ url : avec Prisma 7, la chaîne de connexion est configurée en dehors du schéma (dans prisma.config.ts) et le client se connecte via un adaptateur de pilote, c’est pourquoi @prisma/adapter-pg et pg apparaissent dans les dépendances. Pour connaître l’intégralité des étapes de configuration, consultez la configuration de Prisma 7 avec PostgreSQL dans un projet TypeScript Node.js.
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])
}
Configuration du client
L’interface utilisateur est une application React construite avec Vite, stylisée à l’aide de Tailwind CSS et ShadCN UI, et gérée via React Router. La spécification associe chaque étape serveur à une interface client correspondante :
- Les pages
/loginet/registersont créées à partir des composants de formulaire de ShadCN. Après un accès réussi, le jeton est enregistré et l’utilisateur est redirigé vers le tableau de bord situé à/. - Un tableau de bord qui affiche les films sous forme de grille de cartes ShadCN. Chaque carte dispose d’un bouton « Ajouter à la liste de visionnage » et d’un bouton « Supprimer un film », et la suppression doit mettre à jour immédiatement la grille.
- Une page
/my-watchlistqui liste les films enregistrés par l’utilisateur, ainsi que leurreviewet leurrating. - Un processus d’édition des entrées de la liste de visionnage, soit via un dialogue ShadCN, soit sur une page distincte, l’interface étant mise à jour dès que la modification est réussie.
Le contrat API
POST /registerenregistre un utilisateur ; aucune authentification requise.POST /loginrenvoie un jeton d’accès JWT ; aucune authentification requise.GET /moviesliste tous les films ; authentification requise.DELETE /movies/:idsupprime un film du catalogue ; authentification requise.GET /watchlistaffiche les entrées de l’utilisateur actuel, en utilisantincludede Prisma pour afficher les détails du film ; authentification requise.POST /watchlist/:movieIdajoute un film à la liste de suivi de l’utilisateur actuel ; authentification requise.PATCH /watchlist/:idmet à jour l’avis et la note sans vérification de propriétaire ; authentification requise.DELETE /watchlist/:idsupprime une entrée ; authentification requise et réservée au propriétaire.
Les mots de passe doivent être hachés avec bcrypt.
Inscription et connexion
register rejette une demande avec un code 400 si un champ manque, hache le mot de passe à l’aide d’un sel bcrypt avec un niveau de complexité de 12, crée l’utilisateur, puis génère immédiatement un JWT contenant son id, name et email, ce dernier expirant après un jour. La fonction login recherche l’utilisateur par e-mail, compare le mot de passe à l’aide de bcrypt.compare, et renvoie un token du même type. Les deux gestionnaires ne retournent que les champs d’utilisateur sécurisés, jamais le 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,
});
}
};
Points à améliorer :
- Pour la connexion, un seul message est utilisé tant pour un e-mail inconnu que pour un mot de passe incorrect, ce qui empêche de savoir quels comptes existent. Un code 401 plutôt que 400 permettrait de décrire plus précisément l’échec.
error brut au client. Il faut le journaliser sur le serveur et ne renvoyer qu’un message.process.env.JWT_SECRET en tant que chaîne de caractères permet d’empêcher les erreurs de compilation, mais pas celles en temps de exécution ; si cette variable fait défaut, la signature échoue à chaque requête. Vérifiez les variables d’environnement nécessaires au démarrage.Pour une version renforcée de ce flux, consultez l’authentification JWT prête pour la production dans les API Node.js.
Le middleware d’authentification
Le middleware lit l’en-tête Authorization, prend la partie suivant le premier espace comme jeton, et renvoie 401 en l’absence de ce dernier. Il vérifie ensuite le jeton à l’aide du secret partagé et ajoute la charge utile décodée à la requête afin que les contrôleurs puissent lire l’id de l’utilisateur. Un jeton invalide ou expiré entraîne également une réponse 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" });
}
};
Le cast (req as any).user fonctionne, mais il élimine la vérification de type dans tous les contrôleurs qui l’utilisent. L’augmentation de l’interface Request d’Express par fusion de déclarations permet de conserver le type de req.user. La lecture des en-têtes accepte également n’importe quel schéma, et pas seulement Bearer.
Dépendances du projet
Le package.json définit la version exacte de l’ensemble des bibliothèques au moment de la rédaction : Express 5, Prisma 7 avec l’adaptateur PostgreSQL, bcrypt, jsonwebtoken et cors, ainsi que TypeScript et ts-node-dev pour un script dev qui redémarre automatiquement en cas de modification. Les plages de versions évoluent rapidement, il est donc nécessaire de vérifier les dernières versions avant de les réutiliser.
{
"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"
}
}
Notez que rien dans cette liste ne charge un fichier .env dans process.env ; vous avez donc besoin d’une solution comme dotenv ou du support des fichiers d’environnement de votre environnement de exécution afin que JWT_SECRET soit disponible.
Points d’entrée relatifs aux films
getMovies renvoie simplement tous les films. deleteMovie analyse le paramètre de route, renvoie 400 pour un ID non numérique et 404 pour un ID inconnu, puis exécute une $transaction qui supprime d’abord toutes les lignes de la liste de suivi faisant référence au film, puis le film lui-même. Supprimer d’abord les éléments dépendants évite une violation de clé étrangère, et la transaction garantit que ces deux étapes réussissent ou échouent ensemble.
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",
});
}
};
Une alternative consiste à déclarer onDelete: Cascade sur la relation movie de la liste de suivi, permettant ainsi à la base de données de supprimer automatiquement les lignes dépendantes. La transaction explicite est plus visible dans le code ; la cascade est plus difficile à oublier.
Points d’entrée de la liste de suivi
Le contrôleur de la liste de suivi contient les règles les plus intéressantes :
getWatchlistfiltre en fonction de l’ID utilisateur présent dans le token et utiliseinclude: { movie: true }afin que chaque entrée arrive accompagnée de son film correspondant.addToWatchlistvalide l’ID du film, vérifie son existence, recherche la clé composéeuserId_movieIdpour rejeter les doublons avec un code 400, puis crée l’entrée avec l’évaluation et la note par défaut.updateWatchlistmet en œuvre la règle collaborative : il vérifie que l’entrée existe mais pas à qui elle appartient. Si uneratingest indiquée, celle-ci doit être interprétée comme un entier allant de 1 à 5. Les champs laissésundefinedsont ignorés par Prisma, de sorte qu’une mise à jour partielle ne touche que les informations envoyées.deleteFromWatchlistcharge l’entrée et renvoie 403 lorsque sonuserIddiffère de celui de l’utilisateur qui effectue la demande, ce qui assure le respect de la règle réservée au propriétaire uniquement.
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",
});
}
};
Deux cas limites à connaître. La vérification des doublons et l’insertion sont des requêtes distinctes, ce qui permet à deux demandes simultanées de passer la vérification ; la contrainte d’unicité protège néanmoins les données, mais la demande perdante reçoit un code 500 à moins que vous ne capturiez la violation. De plus, parseInt est tolérant : "4.7" devient 4 et "3abc" devient 3, donc une vérification plus stricte comme Number.isInteger(Number(rating)) rejette de manière plus fiable les entrées mal formatées.
Connexion des routes
La couche de routage dispose d’un fichier par ressource ainsi que d’un index. Les routes d’authentification sont montées sous /auth, tandis que /movies et /watchlist passent par le middleware authentication au moment du montage, de sorte que chaque gestionnaire derrière eux est protégé sans avoir à répéter la vérification. Le point d’entrée active CORS, analyse les corps JSON, monte tout sous /api et écoute sur le 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}`);
});
En raison de ces préfixes, les vrais chemins sont /api/auth/register, /api/movies et ainsi de suite, et non les chemins bruts indiqués dans le contrat. Assurez-vous que le client et toute documentation API utilisent la même URL de base.
Points clés
- Un modèle de jointure explicite avec une contrainte unique composée vous offre à la fois une protection contre les doublons et une clé de recherche pratique.
req.user et en validant les variables d’environnement au démarrage.