MovieVault Walkthrough: 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. Ви побачите, як модель даних відображає взаємозв’язок типу „багато-до-багатьох“, як правила автентифікації та власності впливають на перевірки у мідлвейрі та контролерах, а також які недоліки у рішенні, створеному за обмежений час, варто виправити перед тим, як використовувати його у продакшені.
Опис проекту 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, яка відображає збережені користувачем фільми разом із оглядом та рейтингом кожного з них. - Процес редагування записів у списку перегляду — через діалогове вікно 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 користувача та закінчує свою дію через один день. Функція 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, тому для доступу до значення JWT_SECRET вам знадобиться щось на кшталт бібліотеки dotenv або підтримка файлів середовища у вашому фреймворку.
Кінцеві точки для фільмів
getMovies просто повертає всі фільми. deleteMovie аналізує параметр маршруту, повертає 400 у разі ненумеричного ID та 404 у разі невідомого ID, а потім запускає $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 та перевірка змінних середовища під час запуску.