MovieVault — гід: API для стварэння спіса абяцоў з Express 5, Prisma 7 і JWT
MovieVault – гэта заведама практыка для выконання на всіх роўнях: API для списку для старання на базе Express 5, Prisma 7 і JWT, з прыміткамі па адгляду ў чынніце пераканання ў правах на власнасць, каскадных адпаведнасцях і обробцы памылак.
Крыніцы: PRISMA SCHEMA SIMPLE TOUCH (BACKEND)
Вправы з жывага кодавання з часамі є распашным спосабом адцэнкавання разработчыкаў full-stack, і яны награджаюць кандыдатаў, якія можуць быстра ператворыць короткія спефікацыі на працюючы, захіщаны API. У гэтым кераванні выкорыстоўваецца адна такая короткая спефікацыя — каталог фільмаў пад назвай MovieVault з персональным спискам для аблікування фільмаў, і яна поўнаважваецца цэлым бэкендам на базе Express, TypeScript і Prisma. Вы пазнаёсце, як модэль дадзеных выражае адносы «многа-на-многа», як правілы аутантыкацыі і власнасці пераадражваюцца ў проверкách мідлвэра і кантролера, а таксама якія недагэчанасці ў рашынце з часовым лімітам варта вылечыць прычымо да запуску ў працэсе.
Спефікацыя MovieVault
Програма дазволяе корыстувачам пераглядаць каталог фільмаў, зберагаць ўснавыя назвы у персональны списак для аблікування і кераваць якімі-то з іх. Неабходныя функцыі ўключаюць:
- Рэгістрацыя і заходжанне.
Сервер выкарыстоўвае Express, TypeScript і Prisma; кліент — React, Tailwind і ShadCN UI.
Асновныя правілы
- Часовы ліміт станавіць 280 хвілін.
- Работа павінна быць вашай сопрацоўнічай працой.
- Катэгорыю
node_modulesпавінна быць выключана за дапамогою файла.gitignore, а файл.env.exampleпавінен быць задаўлены. - Для такіх основных компанентаў, як Button, Card і Input, выкарыстоўванне ShadCN UI є обав’язковым.
- Пакты трэба адрабатваць на сервере, а ўжо пасля — падаць іх корыстніку на кліентскай стороне, прынеймна за дапамогою
console.logабо паведамлення.
Настройка сервера і модэлю дадзеных
Сервер — это проект на Express, напісаны ў TypeScript з Prisma як ORM. У завданні запрашваецца стварыць тры моделі: User (поле id, якое можа быць цэлым числам або строкай, унікальны email, password і name), Movie (id, title, genre, description, imageUrl) і модель списку для адзірвання фільмаў (id, userId, movieId, строка review, якая за замовчаннем ўжо пустая, і цэлае число rating, якае за замовчаннем доравнае 1)). Карыстальнікі і фільмы асоціююцца межаўсяе-межаўсяе через табелю списку для адзірвання, і прынеймна пяць фільмаў должны быць заполненыя з рукапіснага файла JSON у пададзе server/data/movies.json.
Наведзены ў нижчым фрагменте схема рэалізуе гэта за дапамою явнага модэлю з’яеднання. Два аспекты заслуговуюць на увагу. Абмежэнне @@unique([userId, movieId]) означае, што корыстнік не можа зберагчы той самы фільм два разы, а таксама дае Prisma складны ключ пад назвай userId_movieId, які пазней викорыстоўваюць кантролеры для пошуку. Блок datasource не мае атрыбута url: у Prisma 7 стрэлка з’яеднання насталявалася за межамі схемы (у файле prisma.config.ts) і кліент падключаецца через адаптар драйвера, таму ў спісе залежнасцэў прыказаны @prisma/adapter-pg і pg. Па ўсімі крокам насталоўкі адзірніце насталоўку Prisma 7 з PostgreSQL у проекте на 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])
}
Насталоўка кліента
Фронтэнд — это прыжак для даплікаў на React, створаны з Vite, адзначаны Tailwind CSS і ShadCN UI, а маршрутызацыя выканана за дапамою React Router. У спефікацыі кожны серверскі этап супараднаваны з кліентскай адпаведнаю экранам:
- Старонкі
/loginі/registerствораны з компонентаў формы з ShadCN. Пасля успешнага заходжання користувача токен зберагаецца, а ён пераправляецца на панель керування за адресай/. - Панель керування, якая адображае фільмы у вачэйцы з картаклів ShadCN. Кожная картка мае кнопкі „Дадзіць у список для перагляду“ і „Адмахнуць фільм“, пры чым адмахненне павінна негаразу апдэйтаваць вачэйку.
- Старонка
/my-watchlist, якая паказвае фільмы, зберажаныя корыстувачам, а таксамаreviewіratingкожнага з іх. - Процес рэдагавання элементаў списку для перагляду, будзь то чераз дыялогавое вікно ShadCN або окремую старонку, пры чым інтэрфейс апдэйтуецца негаразу пасля успешнага змены.
Контракт API
POST /register— рэгіструе ўжоўка; атрыбуты апраўнення не выкананы.POST /login— вяртае токен доступу JWT; атрыбуты апраўнення не выкананы.GET /movies— паказвае всі фільмы; апраўнення неабходна.DELETE /movies/:id— адчыняе фільм з каталогу; апраўнення неабходна.GET /watchlist— паказвае элементы чатэнні перадварота нынешняга ўжоўка, выкорыстоўваючыincludeпрыметніка Prisma для дадаўання дакладнасцей пра фільмы; апраўнення неабходна.POST /watchlist/:movieId— дадае фільм у чатэнню перадварота нынешняга ўжоўка; апраўнення неабходна.PATCH /watchlist/:id— аднавляе атказ і рэйтынг без пераканання ў правах власніка; апраўнення неабходна.DELETE /watchlist/:id— адчыняе элемент чатэнні; апраўнення неабходна і даступ развярнуты толькі власніку.
Паролі павінны быць хэшаваныя за дапамогою bcrypt.
Рэгістрацыя і заход
Контралер аутэнтыкакціі карае як публічныя, так і іншыя канцэнтры. Функцыя register адхоўчае запит з кодам 400, калі які-небудзь поль не заполнены, гэшуе пароль за дапамою bcrypt з салтам касцы 12, стварае пользователя і негайна падписвае JWT, які містыць id, name і email пользователя, пры чым тэрмін дзейнасці такога JWT складае адны дзень. Функцыя login шукае пользователя па адресе электроннай пошты, порыхуе пароль за дапамою bcrypt.compare і вяртае той самы тип токена. Обе функцыі вяртаюць толькі безпечныя поля пользователя, ніколі не вяртаючы гэша.
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,
});
}
};
Тое, што варта пасілкнуць:
- Пры заходзе на адмінку для невядомай адресы электроннай пошты чы правильнага пароля викорыстоўваецца адны і той жа код памилкі, што не дазволяе выявіць, якія акаунты існуюць. Вярненне коду 401 замест 400 дазволіла бы болей тачна описаць памилку.
error кліенту. Яго трэба зафіксаваць на сервере і вярнуць толькі паведамленне.process.env.JWT_SECRET як строка глушыць компілятор, але не час выканання; якщо гэтая зменная відсутня, падписванне неякшае на кожны запит. Неабходнае перакананне значэнняя сяродовых зменных трэба адрабатаваць пад час запуску.Для адаптаванага варыянта гэтага процесу адзірвання, пазірьце аутентыфікацыю JWT, гатовую да выкорыстання ў API на Node.js.
Мідлвэр аутентыфікацыі
Мідлвэр чытае загалоўку Authorization, берае частку пасля першага прабела як токен і адпавядае кодам 401, калі яго няма. Пасля чаго ён пераканаецца ў правамі токена за дапамою спяльнай таёмнай канфігурацыі і прыўяжае раскодаваны пакет да запиту, каб контролеры моглі прачытаць id пользователя. Некоректны або выйшлы з часу токен таксама прыводзіць да коду 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" });
}
};
Пераклад (req as any).user работае, але ён усуняе перакананне па типу ў кожным контролеры, які яго чытае. Расшырэнне інтарфейсу Request у Express за дапамою злучэння декларацый падтрымвае типізаванне req.user. Аналіз загалоўак таксама прыманяе любую схему, а не толькі Bearer.
Залежнасці проекта
package.json фіксуе версіі компанентаў у момент стварэння: Express 5, Prisma 7 з адаптэрам для PostgreSQL, bcrypt, jsonwebtoken і cors, а таксама TypeScript і ts-node-dev для скрыпта dev, які перзапускаеся пасля змян. Дыапазоны версій быстра зменшваюцца, таму пераканайцеся ў актуальных выданнях пры ўторым викорыстоўванні.
{
"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"
}
}
Заазначыце, што нічога ў гэтым списку не завантажвае файл .env у process.env, таму вам патрэбны такія інструменты як dotenv або падтрымка файлаў налашчэння вашай среды для таго, каб JWT_SECRET быў доступны.
Канцэнтры для фільмаў
getMovies простаўляе ўсі фільмы. deleteMovie аналізуе параметр маршруту: якщо ID не ў формате числа, вона вяртае код 400, а якщо ID невядомы — код 404; пасля чаго запускае $transaction, які спачатку вычышае всі рэкорды справакі пра перагляд, якія асоціююцца з фільмам, а потым сам фільм. Вычышэнне дзецячых рэкордаў спачатку запобегае наявнасці правалянняя зовнішньага ключа, а транзакцыя гарантуе, што оба кроку будуць або успехамі, або няўспэхамі разам.
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",
});
}
};
Альтернатыва — задаць onDelete: Cascade на асоціяцыі movie справакі пра перагляд, чым дазволіць базе дадзеных аўтаматычна вычысіць залежныя рэкорды. Актыўная транзакцыя ёсць болей відчутнаю ў коде; каскад гораздка легва забыць.
Канцэнтры справакі пра перагляд
Контролер справакі пра перагляд мае найцякавейшыя правілы:
getWatchlistфільтруе за ID пользователя з токена і выкарыстоўваеinclude: { movie: true }, тады кожны элемент прыходзіць разам з адпаведным фільмам.addToWatchlistпераканальвае ID фільма, паўтарная пераканальвае, што фільм існуе, шукае кампаундны ключuserId_movieId, ўбачваючы дуплікаты і вяртаючы код 400, а таксама стварае элемент з стандартнымі атрыбутамі адгукнення і рэйтынгу.updateWatchlistрэалізуе правіла саавершання: яна пераканальвае, што элемент існуе, але не выявляе, хто яго володзіць. Якщо ўказаныrating, яго неабходна пераканаліць у цэлычысленную значенне з дыяпазону 1 да 5. Поле, якое залишаеццаundefined, ігнаруецца Prisma, таму частковая адчынка паўтарная тыкайцца толькі таго, што было адправлена.deleteFromWatchlistзавантажвае элемент і вяртае код 403, калі йогоuserIdне збігаецца з ID вызывача, чым адбываецца прыменэнне правіла, якое дазволяе выдалачу толькі власніку.
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",
});
}
};
Есць два крайнія случая, якія трэба ведаць. Перакантрольванне на наявнасць дубліката і адчыненне новай ентры — гэта разны запиты, таму два адночасныя запросы могу оба праясці перакантрольванне; унікальныя абмежэння як і раней захоўваюць даны, але той, хто прағавіў, отрымае код 500, якщо толькі не паспеліце з’явіцься на адхыленне. А функцыя parseInt є паслабленай: "4.7" ператвараецца ў 4, а "3abc" — у 3, таму болей строгія перакантрольвання, такая як Number.isInteger(Number(rating)), болей надзеямо захоўваець ад некоректных даных.
Падключэнне маршрутаў
Слой маршрутаў складаецца з аднаго файла на кожны ресурс плюс індэкс. Маршруты аутантыкацыі размешчаюцца пад /auth, тады як /movies і /watchlist праходзячы через мідлвэр authentication у момент запуску, таму кожны працоўнік, який знаходзится пасля іх, захаваны без неабяжнага пераканальвання. Точка входу актывае CORS, парсуе тэлы JSON, размешчае все пад /api і праслухоўвае на порту 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}`);
});
Чырваючы наявнасць гэтых прыфіксаў, рэальныя шляхі ёсць /api/auth/register, /api/movies і так далей, а не простыя шляхі, зазначаныя ў кантракце. Будзьце впэўнены, што кліент і будова API выкарыстоўваюць аднойчыны базовы URL.
Ключавыя выводы
- Явны модэль з’еднання з компузытным унікальным абмежэнням дае вам як захаванне ад дуплікацый, так і зручны ключ для пошуку.
req.user і пераверкі зменных сераўысу пад час запуску.