Руководство по 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. Вы увидите, как модель данных отражает многократные связи, как правила аутентификации и приватности реализуются через мидлвэр и проверки контроллеров, а также какие недостатки в решении с ограниченным временем следует устранить перед тем, как оно попадёт в производственную среду.
Описание проекта 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 as string заглушает предупреждения компилятора, но не во время выполнения; если переменная отсутствует, подпись токена сбрасывается при каждом запросе. Проверяйте необходимые переменные окружения при запуске.Для усиленной версии этого процесса см. механизм аутентификации 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 анализирует параметр маршрута: при ненумерическом ID возвращается код 400, а при неизвестном — код 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 и проверка переменных окружения при запуске.