Validasi Input dan Pagination API: Best Practice REST API di Express
Admin
Penulis Artikel
Validasi Input dan Pagination API: Best Practice REST API di Express
Sampai artikel sebelumnya, Todo List API kita sudah aman lewat JWT dan API Key. Tapi ada dua masalah yang belum kita sentuh:
Input dari client masih dipercaya mentah-mentah. Kita hanya mengecek
if (!title)secara manual di controller — kalau ada field lain yang perlu divalidasi (misalnya panjang minimal, tipe data, format tertentu), kita harus menulis pengecekan manual berulang-ulang.Endpoint
GET /api/todosmengembalikan semua data sekaligus. Ini baik-baik saja kalau datanya cuma 10, tapi kalau sudah 10.000 data, response akan sangat besar dan lambat.
Artikel ini akan membahas cara memvalidasi input dengan express-validator dan cara menambahkan pagination, filtering, dan sorting pada endpoint yang mengembalikan banyak data.
Artikel ini melanjutkan project Todo List API dari artikel pertama hingga kelima. Setelah ini, kita akan lanjut ke Automated Testing API Node.js dengan Jest dan Supertest.
1. Kenapa Validasi Manual Tidak Cukup?
Analogi: bayangkan kamu jadi panitia pendaftaran ekskul. Kalau setiap formulir yang masuk kamu periksa satu-satu secara manual — cek nama tidak kosong, cek nomor HP formatnya benar, cek kelas sesuai pilihan yang ada — itu akan sangat melelahkan dan gampang ada yang kelewat, apalagi kalau formulirnya banyak dan aturan validasinya makin kompleks. Solusinya, kamu buat checklist standar yang otomatis dicek satu per satu sebelum formulir itu diterima ke meja panitia. express-validator berperan sebagai checklist otomatis ini — kamu tinggal mendefinisikan aturannya sekali, dan setiap request yang masuk otomatis dicek sebelum sampai ke controller.
2. Setup: Install express-validator
npm install express-validator
3. Membuat Validation Rules untuk Todo
mkdir -p src/validators
src/validators/todo.validator.js
const { body, param, query } = require("express-validator");
const createTodoRules = [
body("title")
.trim()
.notEmpty()
.withMessage("Title is required")
.isLength({ min: 3, max: 100 })
.withMessage("Title must be between 3 and 100 characters"),
body("description")
.optional()
.trim()
.isLength({ max: 500 })
.withMessage("Description must not exceed 500 characters"),
];
const updateTodoRules = [
param("id").isMongoId().withMessage("Invalid todo ID format"),
body("title")
.optional()
.trim()
.isLength({ min: 3, max: 100 })
.withMessage("Title must be between 3 and 100 characters"),
body("description")
.optional()
.trim()
.isLength({ max: 500 })
.withMessage("Description must not exceed 500 characters"),
body("completed")
.optional()
.isBoolean()
.withMessage("Completed must be true or false"),
];
const getTodoByIdRules = [param("id").isMongoId().withMessage("Invalid todo ID format")];
const getAllTodosRules = [
query("page")
.optional()
.isInt({ min: 1 })
.withMessage("Page must be a positive integer"),
query("limit")
.optional()
.isInt({ min: 1, max: 100 })
.withMessage("Limit must be between 1 and 100"),
query("completed")
.optional()
.isBoolean()
.withMessage("Completed filter must be true or false"),
query("sortBy")
.optional()
.isIn(["createdAt", "title", "completed"])
.withMessage("sortBy must be one of: createdAt, title, completed"),
query("order")
.optional()
.isIn(["asc", "desc"])
.withMessage("order must be 'asc' or 'desc'"),
];
module.exports = {
createTodoRules,
updateTodoRules,
getTodoByIdRules,
getAllTodosRules,
};
4. Membuat Middleware Penampung Hasil Validasi
Analogi: kalau todo.validator.js di atas adalah daftar checklist-nya, middleware di bawah ini adalah petugas yang membaca hasil checklist itu — kalau ada satu saja poin yang tidak lolos, formulir langsung dikembalikan dengan catatan bagian mana yang salah, tanpa perlu diteruskan ke meja panitia (controller).
src/middlewares/validate.middleware.js
const { validationResult } = require("express-validator");
const AppError = require("../utils/AppError");
function validate(req, res, next) {
const errors = validationResult(req);
if (!errors.isEmpty()) {
const messages = errors.array().map((err) => err.msg);
return next(new AppError(messages.join(", "), 400));
}
next();
}
module.exports = validate;
5. Menerapkan Validasi ke Route
src/routes/todo.routes.js (update penuh)
const express = require("express");
const router = express.Router();
const todoController = require("../controllers/todo.controller");
const { protect } = require("../middlewares/auth.middleware");
const validate = require("../middlewares/validate.middleware");
const {
createTodoRules,
updateTodoRules,
getTodoByIdRules,
getAllTodosRules,
} = require("../validators/todo.validator");
router.use(protect);
router.post("/", createTodoRules, validate, todoController.createTodo);
router.get("/", getAllTodosRules, validate, todoController.getAllTodos);
router.get("/:id", getTodoByIdRules, validate, todoController.getTodoById);
router.put("/:id", updateTodoRules, validate, todoController.updateTodo);
router.delete("/:id", getTodoByIdRules, validate, todoController.deleteTodo);
module.exports = router;
Perhatikan urutan middleware-nya: rules dulu, baru validate, baru controller. Rules bertugas mendefinisikan aturan & menjalankan pengecekan, validate bertugas membaca hasilnya dan menghentikan request kalau ada yang gagal.
6. Menambahkan Pagination, Filtering, dan Sorting
Analogi: tanpa pagination, endpoint GET /api/todos itu seperti perpustakaan yang menyerahkan seluruh isi rak buku sekaligus ke satu siswa yang cuma butuh 10 buku. Pagination membuat perpustakaan hanya menyerahkan “satu rak kecil” per permintaan (misalnya 10 buku per halaman), dan siswa bisa minta rak berikutnya kalau masih kurang.
6.1 Update Service: Query dengan Filter, Sort, Pagination
src/services/todo.service.js (update penuh)
const Todo = require("../models/todo.model");
async function createTodo(data) {
const todo = new Todo({
title: data.title,
description: data.description,
owner: data.owner,
});
return await todo.save();
}
async function getAllTodos(ownerId, queryOptions) {
const { page = 1, limit = 10, completed, sortBy = "createdAt", order = "desc" } = queryOptions;
const filter = { owner: ownerId };
// Filter berdasarkan status completed, hanya jika parameter dikirim
if (completed !== undefined) {
filter.completed = completed === "true";
}
const sortDirection = order === "asc" ? 1 : -1;
const skip = (Number(page) - 1) * Number(limit);
const [todos, totalItems] = await Promise.all([
Todo.find(filter)
.sort({ [sortBy]: sortDirection })
.skip(skip)
.limit(Number(limit)),
Todo.countDocuments(filter),
]);
const totalPages = Math.ceil(totalItems / Number(limit));
return {
todos,
pagination: {
currentPage: Number(page),
totalPages,
totalItems,
itemsPerPage: Number(limit),
},
};
}
async function getAllTodosForAdmin(queryOptions) {
const { page = 1, limit = 10, completed, sortBy = "createdAt", order = "desc" } = queryOptions;
const filter = {};
if (completed !== undefined) {
filter.completed = completed === "true";
}
const sortDirection = order === "asc" ? 1 : -1;
const skip = (Number(page) - 1) * Number(limit);
const [todos, totalItems] = await Promise.all([
Todo.find(filter)
.sort({ [sortBy]: sortDirection })
.skip(skip)
.limit(Number(limit))
.populate("owner", "name email"),
Todo.countDocuments(filter),
]);
const totalPages = Math.ceil(totalItems / Number(limit));
return {
todos,
pagination: {
currentPage: Number(page),
totalPages,
totalItems,
itemsPerPage: Number(limit),
},
};
}
async function getTodoById(id) {
return await Todo.findById(id);
}
async function updateTodo(id, data) {
return await Todo.findByIdAndUpdate(
id,
{
title: data.title,
description: data.description,
completed: data.completed,
},
{ new: true, runValidators: true }
);
}
async function deleteTodo(id) {
return await Todo.findByIdAndDelete(id);
}
async function getSummaryStats() {
const totalTodos = await Todo.countDocuments();
const completedTodos = await Todo.countDocuments({ completed: true });
const pendingTodos = totalTodos - completedTodos;
return { totalTodos, completedTodos, pendingTodos };
}
module.exports = {
createTodo,
getAllTodos,
getAllTodosForAdmin,
getTodoById,
updateTodo,
deleteTodo,
getSummaryStats,
};
6.2 Update Controller: Meneruskan Query Parameter ke Service
src/controllers/todo.controller.js (update penuh)
const todoService = require("../services/todo.service");
const AppError = require("../utils/AppError");
const catchAsync = require("../utils/catchAsync");
const createTodo = catchAsync(async (req, res, next) => {
const { title, description } = req.body;
const todo = await todoService.createTodo({
title,
description,
owner: req.user._id,
});
res.status(201).json({
success: true,
message: "Todo created successfully",
data: todo,
});
});
const getAllTodos = catchAsync(async (req, res, next) => {
const { page, limit, completed, sortBy, order } = req.query;
const queryOptions = { page, limit, completed, sortBy, order };
const result =
req.user.role === "admin"
? await todoService.getAllTodosForAdmin(queryOptions)
: await todoService.getAllTodos(req.user._id, queryOptions);
res.status(200).json({
success: true,
message: "Todos retrieved successfully",
data: result.todos,
pagination: result.pagination,
});
});
const getTodoById = catchAsync(async (req, res, next) => {
const { id } = req.params;
const todo = await todoService.getTodoById(id);
if (!todo) {
return next(new AppError("Todo not found", 404));
}
const isOwner = todo.owner.toString() === req.user._id.toString();
if (!isOwner && req.user.role !== "admin") {
return next(new AppError("You do not have permission to access this todo", 403));
}
res.status(200).json({
success: true,
message: "Todo retrieved successfully",
data: todo,
});
});
const updateTodo = catchAsync(async (req, res, next) => {
const { id } = req.params;
const { title, description, completed } = req.body;
const existingTodo = await todoService.getTodoById(id);
if (!existingTodo) {
return next(new AppError("Todo not found", 404));
}
const isOwner = existingTodo.owner.toString() === req.user._id.toString();
if (!isOwner && req.user.role !== "admin") {
return next(new AppError("You do not have permission to update this todo", 403));
}
const updatedTodo = await todoService.updateTodo(id, { title, description, completed });
res.status(200).json({
success: true,
message: "Todo updated successfully",
data: updatedTodo,
});
});
const deleteTodo = catchAsync(async (req, res, next) => {
const { id } = req.params;
const existingTodo = await todoService.getTodoById(id);
if (!existingTodo) {
return next(new AppError("Todo not found", 404));
}
const isOwner = existingTodo.owner.toString() === req.user._id.toString();
if (!isOwner && req.user.role !== "admin") {
return next(new AppError("You do not have permission to delete this todo", 403));
}
await todoService.deleteTodo(id);
res.status(200).json({
success: true,
message: "Todo deleted successfully",
data: existingTodo,
});
});
module.exports = {
createTodo,
getAllTodos,
getTodoById,
updateTodo,
deleteTodo,
};
Catatan: pengecekan manual if (!title) di createTodo sudah dihapus, karena sekarang validasinya sudah ditangani penuh oleh createTodoRules + middleware validate sebelum request sampai ke controller ini.
7. Struktur Folder Setelah Artikel Ini
todo-api/
├── src/
│ ├── config/
│ │ └── db.js
│ ├── middlewares/
│ │ ├── logger.middleware.js
│ │ ├── notFound.middleware.js
│ │ ├── errorHandler.middleware.js
│ │ ├── auth.middleware.js
│ │ ├── restrictTo.middleware.js
│ │ ├── apiKey.middleware.js
│ │ └── validate.middleware.js
│ ├── validators/
│ │ └── todo.validator.js
│ ├── models/
│ │ ├── todo.model.js
│ │ └── user.model.js
│ ├── services/
│ │ ├── todo.service.js
│ │ └── auth.service.js
│ ├── controllers/
│ │ ├── todo.controller.js
│ │ ├── auth.controller.js
│ │ └── stats.controller.js
│ ├── routes/
│ │ ├── todo.routes.js
│ │ ├── auth.routes.js
│ │ └── stats.routes.js
│ ├── utils/
│ │ ├── AppError.js
│ │ └── catchAsync.js
│ ├── app.js
│ └── server.js
├── .env
├── .env.example
├── .gitignore
└── package.json
8. Menguji Validasi dan Pagination
npm run dev
Tes 1 — Title terlalu pendek (harus ditolak).
curl -X POST http://localhost:3000/api/todos \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <TOKEN>" \
-d '{"title": "ab"}'
{
"success": false,
"message": "Title must be between 3 and 100 characters"
}
Tes 2 — ID todo dengan format tidak valid (harus ditolak sebelum sampai ke database).
curl http://localhost:3000/api/todos/id-ngasal \
-H "Authorization: Bearer <TOKEN>"
{
"success": false,
"message": "Invalid todo ID format"
}
Tes 3 — Ambil halaman kedua, 5 data per halaman, hanya yang belum selesai, urut dari judul A-Z.
curl "http://localhost:3000/api/todos?page=2&limit=5&completed=false&sortBy=title&order=asc" \
-H "Authorization: Bearer <TOKEN>"
{
"success": true,
"message": "Todos retrieved successfully",
"data": [
{ "_id": "665f...", "title": "Kerjakan PR Matematika", "completed": false, "...": "..." }
],
"pagination": {
"currentPage": 2,
"totalPages": 3,
"totalItems": 13,
"itemsPerPage": 5
}
}
Tes 4 — Parameter limit di luar batas (harus ditolak).
curl "http://localhost:3000/api/todos?limit=500" \
-H "Authorization: Bearer <TOKEN>"
{
"success": false,
"message": "Limit must be between 1 and 100"
}
9. Perbandingan Sebelum dan Sesudah
Aspek | Sebelum | Sesudah |
|---|---|---|
Validasi input | Manual, hanya cek title kosong | Terpusat lewat express-validator, aturan lengkap per field |
Response saat data banyak | Semua data dikirim sekaligus | Dibagi per halaman (pagination) |
Filter data | Tidak ada | Bisa filter berdasarkan status completed |
Urutan data | Selalu terbaru dulu (hardcode) | Bisa diatur lewat sortBy dan order |
Validasi format ID | Error mentah dari MongoDB (CastError) | Ditolak lebih awal dengan pesan jelas |
10. Kesimpulan
Dengan menambahkan express-validator, input yang masuk ke Todo List API sekarang benar-benar diperiksa sebelum sampai ke controller — bukan lagi sekadar pengecekan manual seadanya. Ditambah dengan pagination, filtering, dan sorting, endpoint GET /api/todos sekarang siap menghadapi data dalam jumlah besar tanpa membebani server maupun client.
API kita sekarang sudah cukup solid dari sisi struktur, keamanan, dan kualitas data. Tapi sejauh ini kita hanya menguji API secara manual lewat curl satu-satu. Di artikel selanjutnya, kita akan belajar menulis automated test menggunakan Jest dan Supertest, supaya pengujian bisa dijalankan otomatis dan berulang tanpa perlu mengetik curl setiap kali ada perubahan kode.
Lanjutkan ke artikel berikutnya: Automated Testing API Node.js dengan Jest dan Supertest
Artikel Terkait
Panduan Membuat Animasi Animated on Scroll di Website Anda
Pelajari cara membuat animasi menarik pada scroll di website Anda dengan tutorial ini. Tampilkan konten dengan cara yang lebih interaktif!
Cara Menangani Error dan HTTP Status Code yang Benar
cara menentukan HTTP status code yang benar untuk tiap jenis error, dan cara membuat custom Error
Cara Restrukturisasi CRUD Node.js: Dari Function ke Folder Controller-Service-Model
cara merestrukturisasi kode CRUD dari kumpulan function menjadi struktur folder yang rapi menggunakan pola Controller-Service-Model.
