Przewodnik po MovieVault: API lista obserwowanych filmów z użyciem Express 5, Prisma 7 i JWT
MovieVault – ćwiczenie typu full-stack z ograniczonym czasem realizacji: API do zarządzania listą filmów na Express 5, Prisma 7 i JWT, wraz z notatkami dotyczącymi weryfikacji praw własności, mechanizmów kaskadowych oraz obsługi błędów.
Ćwiczenia z kodowania na żywo w określonym czasie to powszechny sposób oceny programistów full-stack, a one nagradzają kandydatów, którzy potrafią szybko przekształcić krótki opis w działającą, zabezpieczoną API. Ten przewodnik wykorzystuje właśnie taki krótki opis – katalog filmów o nazwie MovieVault z osobistą listą oglądanych filmów – i łączy go z kompletnym backendem opartym na Express, TypeScript i Prisma. Zobaczysz, jak model danych reprezentuje związek typu wiele-do-wielu, jak reguły autoryzacji i zarządzania prawami przenoszą się na mechanizmy middleware oraz sprawdzenia w kontrolerach, a także które niedoskonałości w rozwiązaniu realizowanym w ograniczonym czasie warto naprawić, zanim trafi ono do środowiska produkcyjnego.
Opis projektu MovieVault
Aplikacja umożliwia użytkownikom przeglądanie katalogu filmów, zapisywanie tytułów do osobistej listy oglądanych filmów oraz zarządzanie nimi. Wymagane funkcje to:
- Rejestracja i logowanie.
Serwer wykorzystuje Express, TypeScript i Prisma; klient korzysta z React, Tailwind i ShadCN UI.
Zasady ogólne
- Czas trwania zadania to 280 minut.
- Praca musi być własnym twórczością użytkownika.
- Katalog
node_modulesmusi zostać wykluczony za pomocą pliku.gitignore, a plik.env.examplemusi zostać dodany do komitetu. - ShadCN UI jest obowiązkowy dla podstawowych komponentów takich jak Button, Card i Input.
- Błędy muszą być obsługiwane na serwerze, a ich informacje muszą być pokazywane użytkownikowi po stronie klienta, przynajmniej za pomocą
console.loglub komunikatu toast.
Ustawianie serwera i modelu danych
Serwer to projekt Express napisany w TypeScript z Prismą jako ORM. W specyfikacji wymagane są trzy modele: User (z id, które może być liczbą całkowitą lub łańcuchem, unikalnym email, password oraz name), Movie (id, title, genre, description, imageUrl) oraz model listy do oglądania (id, userId, movieId, łańcuch review z domyślnie pustym wartością oraz liczbę całkowitą rating z domyślną wartością 1). Użytkownicy i filmy są powiązani w relacji wiele-do-wielu za pośrednictwem tabeli listy do oglądania, a co najmniej pięć filmów musi zostać załadowanych z ręcznie napisanego pliku JSON znajdującego się w adresie server/data/movies.json.
Schemat poniżej realizuje to poprzez wyraźny model łączenia. Dwie kwestie wymagają uwagi. Ograniczenie @@unique([userId, movieId]) oznacza, że użytkownik nie może dwa razy zapisywać tego samego filmu, a także dostarcza Prismie złożony klucz o nazwie userId_movieId, który kontrolery wykorzystują później do wyszukiwań. Blok datasource nie zawiera pola url: w Prismie 7 ciąg połączeniowy jest konfigurowany poza schematem (w pliku prisma.config.ts), a klient łączy się za pośrednictwem adaptera sterownika, dlatego w zależnościach pojawiają się @prisma/adapter-pg oraz pg. Pełne instrukcje konfiguracji znajdują się pod adresem konfiguracja Prismy 7 z PostgreSQL w projekcie 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])
}
Konfiguracja klienta
Frontend to aplikacja React zbudowana przy użyciu Vite, stylizowana za pomocą Tailwind CSS i ShadCN UI, a zarządzanie trasami odbywa się za pomocą React Router. Specyfikacja łączy każdy etap na stronie serwera z odpowiednią ekranem klienta:
- Strony
/logini/registerstworzone z komponentów formularzy ShadCN. Po udanym logowaniu token jest przechowywany, a użytkownik jest przekierowywany na panel sterowania pod adresem/. - Panel sterowania, który wyświetla filmy w postaci siatki karty ShadCN. Każda karta ma przyciski „Dodaj do listy obserwowanych” oraz „Usuń film”, a usunięcie musi natychmiast zaktualizować siatkę.
- Strona
/my-watchlist, która wyświetla zapisane przez użytkownika filmy wraz z ichrecenzjąioceną. - Proces edycji pozycji na liście obserwowanych – albo za pomocą okna dialogowego ShadCN, albo na osobnej stronie – przy czym interfejs aktualizuje się natychmiast po pomyślnej zmianie.
Umowa API
POST /registerrejestruje użytkownika; nie wymaga autoryzacji.POST /loginzwraca token dostępu JWT; nie wymaga autoryzacji.GET /movieswyświetla wszystkie filmy; wymaga autoryzacji.DELETE /movies/:idusuwa film z katalogu; wymaga autoryzacji.GET /watchlistpokazuje wpisy bieżącego użytkownika, wykorzystującincludez Prismy do dodania szczegółów filmu; wymaga autoryzacji.POST /watchlist/:movieIddodaje film do listy obserwowanych filmów bieżącego użytkownika; wymaga autoryzacji.PATCH /watchlist/:idaktualizuje recenzję i ocenę bez sprawdzania właściciela; wymaga autoryzacji.DELETE /watchlist/:idusuwa wpis; wymaga autoryzacji i jest dostępne tylko dla właściciela.
Hasła muszą być hasowane za pomocą bcrypt.
Kontroler autoryzacji obsługuje oba publiczne punkty końcowe. Funkcja register odrzuca żądanie z kodem błędu 400, gdy brakuje jakiegoś pola, hasło jest haszowane przy użyciu soli bcrypt o stopniu trudności 12, tworzony jest użytkownik, a następnie natychmiast wydawany jest token JWT zawierający id, name i email użytkownika, który wygasa po jednym dniu. Funkcja login wyszukuje użytkownika pod adresem e-mail, porównuje hasło za pomocą bcrypt.compare i zwraca ten sam typ tokena. Obie funkcje zwracają jedynie bezpieczne pola użytkownika, nigdy sam hasz.
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,
});
}
};
Kwestie wymagające poprawy:
- Podczas logowania używana jest jedna wiadomość dla nieznanych adresów e-mail oraz błędnych haseł, co uniemożliwia ujawnienie, jakie konta istnieją. Zwrócenie kodu błędu 401 zamiast 400 pozwoliłoby dokładniej opisać przyczynę błędu.
error do klienta. Zapisz go na serwerze i zwróć tylko komunikat.process.env.JWT_SECRET as string tłumi kompilator, ale nie czas wykonywania; jeśli zmienna jest brakująca, podpisywanie zawodzi przy każdej prośbie. Waliduj wymagane zmienne środowiskowe przy uruchamianiu.Aby zobaczyć wzmocnioną wersję tego procesu, sprawdź autoryzację JWT gotową do użycia w API Node.js.
Middleware autoryzacji
Middlewarstwa odczytuje nagłówek Authorization, bierze część po pierwszej przestrzeni jako token i zwraca kod 401, gdy ten jest nieobecny. Następnie weryfikuje token za pomocą wspólnego sekretu i dołącza zdekodowany payload do żądania, aby kontrolery mogły odczytać id użytkownika. Nieprawidłowy lub wygasły token również powoduje kod 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" });
}
};
Przekształcenie (req as any).user działa, ale eliminuje sprawdzanie typu we wszystkich kontrolerach, które je używają. Rozszerzenie interfejsu Request w Express poprzez łączenie deklaracji zapewnia typizację req.user. Parsowanie nagłówków przyjmuje również dowolny schemat, a nie tylko Bearer.
Zależności projektu
package.json określa wersje komponentów użytych w momencie tworzenia projektu: Express 5, Prisma 7 z adapterem PostgreSQL, bcrypt, jsonwebtoken i cors, a także TypeScript oraz ts-node-dev do uruchamiania skryptu dev, który restartuje projekt po zmianach. Wersje komponentów zmieniają się szybko, dlatego przed ponownym użyciem należy sprawdzić najnowsze wydania.
{
"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"
}
}
Należy pamiętać, że żaden z elementów listy nie ładuje pliku .env do zmiennej process.env, więc potrzebny jest narzędzie takie jak dotenv lub obsługa plików konfiguracyjnych w Twoim środowisku wykonawczym, aby JWT_SECRET był dostępny.
Koniecpunkty dotyczące filmów
getMovies po prostu zwraca wszystkie filmy. deleteMovie analizuje parametr trasy, zwraca 400 w przypadku niecyfrowego ID oraz 404 przy nieznanych ID, a następnie uruchamia $transaction, który najpierw usuwa wszystkie wiersze listy obserwowanych filmów odnoszące się do danego filmu, a potem sam film. Usunięcie najpierw elementów zależnych zapobiega naruszeniu reguł klucza obcego, a transakcja gwarantuje, że oba kroki będą skuteczne albo nieudane jednocześnie.
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",
});
}
};
Alternatywą jest ustawienie onDelete: Cascade na relacji movie listy obserwowanych filmów, co pozwala bazie danych automatycznie usunąć zależne wiersze. Wyraźna transakcja jest lepiej widoczna w kodzie, natomiast mechanizm kaskadowy trudniej zapomnieć.
Koniecpunkty listy obserwowanych filmów
Kontroler listy obserwowanych filmów zawiera najciekawsze reguły:
getWatchlistfiltruje według ID użytkownika z tokena i używainclude: { movie: true }, dzięki czemu każda pozycja przychodzi wraz ze swoim filmem.addToWatchlistweryfikuje ID filmu, potwierdza jego istnienie, wyszukuje klucz złożonyuserId_movieId, aby odrzucić duplikaty z kodem błędu 400, a następnie tworzy pozycję z domyślną recenzją i oceną.updateWatchlistwdraża zasadę współpracy: sprawdza, czy pozycja istnieje, ale nie kto jest jej właścicielem. Jeśli istniejerating, musi zostać on przetłumaczony na liczbę całkowitą od 1 do 5. Pola pozostawione naundefinedsą ignorowane przez Prismę, więc częściowa aktualizacja dotyczy tylko tych danych, które zostały wysłane.deleteFromWatchlistładuje pozycję i zwraca błąd 403, gdy jejuserIdróżni się od ID osoby wykonującej żądanie, co zapewnia przestrzeganie zasady dostępu tylko dla właściciela.
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",
});
}
};
Dwa przypadki krawędziowe, o których należy wiedzieć. Sprawdzenie duplikatów i operacja wstawiania to oddzielne zapytania, więc dwa jednoczesne żądania mogą oba przejść tę kontrolę; ograniczenie unikalności nadal chroni dane, ale ten, który przegrywa, otrzymuje błąd 500, chyba że złapiesz to naruszenie. Ponadto parseInt jest łagodny: "4.7" staje się 4, a "3abc" – 3, więc bardziej rygorystyczna kontrola, taka jak Number.isInteger(Number(rating)), bardziej niezawodnie odrzuca niewłaściwe dane.
Łączenie tras
Kwarterowanie ma jeden plik na zasób plus indeks. Trasy autoryzacyjne są montowane pod adresem /auth, natomiast /movies i /watchlist przechodzą przez middleware authentication w momencie montowania, dzięki czemu każdy obsługiwanie zapytania za nimi jest chronione bez konieczności powtarzania sprawdzenia. Punkt wejścia umożliwia funkcjonowanie CORS, parsuje treści w formacie JSON, montuje wszystko pod adresem /api i nasłuchuje na porcie 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}`);
});
Z powodu tych przedrostków rzeczywiste ścieżki to /api/auth/register, /api/movies itp., a nie proste ścieżki opisane w kontrakcie. Upewnij się, że klient oraz wszelka dokumentacja API używają tej samej podstawowej adresu URL.
Główne wnioski
- Jasny model łączenia z złożonym ograniczeniem unikalności zapewnia zarówno ochronę przed duplikatami, jak i wygodny klucz do wyszukiwania.
req.user oraz weryfikacja zmiennych środowiskowych przy uruchamianiu.