Validasi Input dan Pagination API: Best Practice REST API di Express

Tutorial
Jul 18, 2026
9 mnt baca
13 tayangan
Validasi Input dan Pagination API: Best Practice REST API di Express
A

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:

  1. 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.

  2. Endpoint GET /api/todos mengembalikan 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!

Baca Artikel
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

Baca Artikel
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.

Baca Artikel