Membuat REST API yang jalan itu mudah. Membuat yang tetap enak dirawat setelah enam bulan dan tidak bocor datanya, itu soal lain. Tulisan ini menyusun API Express dan MongoDB dengan penekanan pada bagian yang biasanya baru terasa penting setelah aplikasi dipakai orang.
Setup Proyek
mkdir my-api && cd my-api
npm init -y
npm install express mongoose dotenv bcryptjs jsonwebtoken zod helmet express-rate-limit
npm install -D nodemon
Tiga paket terakhir sering dilewati padahal murah dipasang: zod untuk validasi, helmet untuk header keamanan, express-rate-limit untuk membatasi laju permintaan.
Struktur Folder
src/
βββ config/ # Koneksi database, pembacaan env
βββ models/ # Skema Mongoose
βββ controllers/ # Penanganan request dan response
βββ services/ # Logika bisnis
βββ routes/ # Definisi endpoint
βββ middleware/ # Auth, validasi, penanganan error
βββ index.js
Pemisahan controllers dan services terasa berlebihan di awal, tapi terbayar saat logika yang sama dibutuhkan di tempat lain, misalnya di perintah CLI atau pekerjaan terjadwal. Controller sebaiknya hanya mengurus HTTP: membaca request, memanggil service, mengirim response. Service tidak perlu tahu apa pun tentang HTTP.
Konfigurasi yang Gagal Lebih Awal
Aplikasi yang menyala dengan konfigurasi setengah jadi akan gagal di tempat yang membingungkan. Lebih baik berhenti sejak awal:
// src/config/env.js
import { z } from 'zod';
const schema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
PORT: z.coerce.number().default(3000),
MONGODB_URI: z.string().url(),
JWT_SECRET: z.string().min(32),
});
const parsed = schema.safeParse(process.env);
if (!parsed.success) {
console.error('Konfigurasi tidak valid:', parsed.error.flatten().fieldErrors);
process.exit(1);
}
export const env = parsed.data;
Syarat min(32) pada JWT_SECRET bukan hiasan. Secret pendek bisa ditebak dengan brute force, dan token JWT yang bisa dipalsukan berarti siapa pun bisa menyamar jadi pengguna mana pun.
Model User
import mongoose from 'mongoose';
import bcrypt from 'bcryptjs';
const userSchema = new mongoose.Schema({
name: { type: String, required: true, trim: true },
email: {
type: String,
required: true,
unique: true,
lowercase: true,
trim: true,
index: true,
},
password: { type: String, required: true, select: false },
role: { type: String, enum: ['user', 'admin'], default: 'user' },
}, { timestamps: true });
userSchema.pre('save', async function () {
if (!this.isModified('password')) return;
this.password = await bcrypt.hash(this.password, 12);
});
userSchema.methods.comparePassword = function (candidate) {
return bcrypt.compare(candidate, this.password);
};
export default mongoose.model('User', userSchema);
Dua detail penting di sini.
select: false pada password membuat field itu tidak ikut terbawa pada query biasa. Ini jaring pengaman terhadap kesalahan paling umum di API pemula: mengirim hash password ke klien karena lupa menyaring field. Saat memang butuh, panggil .select('+password') secara eksplisit.
Hook pre('save') memastikan password selalu di-hash, di mana pun user dibuat. Menaruh proses hashing di controller berarti Anda harus ingat melakukannya di setiap tempat, dan suatu hari akan ada yang lupa. Angka 12 adalah jumlah putaran bcrypt, kompromi yang wajar antara keamanan dan waktu komputasi.
Validasi Input
Jangan pernah percaya isi request. Validasi di satu tempat lewat middleware:
export const validate = (schema) => (req, res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: 'Validasi gagal',
details: result.error.flatten().fieldErrors,
});
}
req.body = result.data; // Hanya field yang lolos skema
next();
};
Baris req.body = result.data penting. Ia membuang field yang tidak dikenal, sehingga penyerang tidak bisa menyelipkan role: "admin" ke dalam permintaan pendaftaran dan ikut tersimpan lewat mass assignment.
Endpoint CRUD
import { Router } from 'express';
import { z } from 'zod';
import { validate } from '../middleware/validate.js';
import { auth, requireRole } from '../middleware/auth.js';
import * as controller from '../controllers/user.controller.js';
const router = Router();
const createUserSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
password: z.string().min(8),
});
router.get('/', auth, controller.list);
router.get('/:id', auth, controller.getById);
router.post('/', validate(createUserSchema), controller.create);
router.patch('/:id', auth, controller.update);
router.delete('/:id', auth, requireRole('admin'), controller.remove);
export default router;
Perhatikan PATCH dipakai untuk pembaruan sebagian, bukan PUT. Secara semantik PUT berarti mengganti seluruh isi sumber daya, dan itu jarang yang sebenarnya diinginkan.
Selalu Paginasi Daftar
Endpoint yang mengembalikan seluruh isi koleksi akan baik-baik saja dengan lima puluh baris, lalu menjatuhkan server saat datanya mencapai lima ratus ribu.
export async function list(req, res) {
const page = Math.max(1, Number(req.query.page) || 1);
const limit = Math.min(100, Number(req.query.limit) || 20);
const [items, total] = await Promise.all([
User.find().skip((page - 1) * limit).limit(limit).lean(),
User.countDocuments(),
]);
res.json({
data: items,
meta: { page, limit, total, pages: Math.ceil(total / limit) },
});
}
Batas atas pada limit mencegah seseorang meminta sejuta baris sekaligus. Metode .lean() mengembalikan objek JavaScript biasa alih-alih dokumen Mongoose lengkap, dan pada daftar yang panjang selisih kecepatannya terasa.
Middleware Autentikasi
import jwt from 'jsonwebtoken';
import { env } from '../config/env.js';
export function auth(req, res, next) {
const header = req.header('Authorization') || '';
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
if (!token) {
return res.status(401).json({ error: 'Token tidak ditemukan' });
}
try {
req.user = jwt.verify(token, env.JWT_SECRET);
next();
} catch {
return res.status(401).json({ error: 'Token tidak valid' });
}
}
export const requireRole = (role) => (req, res, next) => {
if (req.user?.role !== role) {
return res.status(403).json({ error: 'Akses ditolak' });
}
next();
};
Token yang tidak valid harus dijawab 401, bukan 400. Kode 401 berarti βAnda belum terbukti siapaβ, sementara 403 berarti βAnda dikenali, tapi tidak berhakβ. Membedakan keduanya memudahkan klien menentukan apakah perlu meminta login ulang.
Isi payload JWT hanya dengan identitas dan peran. Jangan menaruh data sensitif di sana, karena payload JWT hanya di-encode base64, bukan dienkripsi, dan siapa pun bisa membacanya.
Penanganan Error Terpusat
Menulis try/catch di setiap controller cepat membosankan dan gampang terlewat. Express versi terbaru meneruskan error dari fungsi async secara otomatis ke error handler:
export function errorHandler(err, req, res, next) {
console.error(err);
if (err.name === 'ValidationError') {
return res.status(400).json({ error: 'Data tidak valid' });
}
if (err.code === 11000) {
return res.status(409).json({ error: 'Data sudah ada' });
}
res.status(err.status || 500).json({
error: env.NODE_ENV === 'production' ? 'Terjadi kesalahan' : err.message,
});
}
Kode 11000 dari MongoDB berarti pelanggaran indeks unik, misalnya email yang sudah terdaftar. Menerjemahkannya jadi 409 jauh lebih berguna bagi klien daripada 500 tanpa penjelasan.
Perhatikan juga pesan error disembunyikan di production. Jejak tumpukan dan pesan internal bisa membocorkan struktur database dan jalur file kepada penyerang.
Lapisan Keamanan Dasar
import helmet from 'helmet';
import rateLimit from 'express-rate-limit';
app.use(helmet());
app.use(express.json({ limit: '10kb' }));
app.use('/api/auth', rateLimit({
windowMs: 15 * 60 * 1000,
max: 20,
message: { error: 'Terlalu banyak percobaan, coba lagi nanti' },
}));
Batas 10kb pada body JSON mencegah permintaan raksasa menghabiskan memori. Pembatasan laju khusus pada endpoint autentikasi mempersulit serangan menebak password secara beruntun.
Satu hal lagi yang sering terlewat: pastikan .env masuk ke .gitignore sejak commit pertama. Secret yang pernah masuk riwayat Git tetap bisa diambil meski file-nya sudah dihapus, dan satu-satunya perbaikan yang benar adalah mengganti secret tersebut.
Penutup
Yang membedakan API latihan dengan API yang layak dipakai bukan jumlah endpoint, melainkan apa yang terjadi ketika sesuatu berjalan di luar dugaan: input aneh, token kedaluwarsa, koneksi database putus, atau seseorang mengirim sepuluh ribu permintaan per menit. Bangun jawaban untuk kondisi-kondisi itu sejak awal, karena menambahkannya belakangan selalu jauh lebih mahal.