Server tahap CRUD mempercayai klien sepenuhnya — dan itu terbukti berbahaya. Coba kirim {"kode":"TG-003","judul":123}: respons 201 dan document tersimpan dengan judul berupa angka. Kirim body kosong: dijawab 500, padahal kesalahan jelas milik klien. Kirim kode yang sudah dipakai: 500 lagi, sementara console server penuh jejak galat duplikat E11000. Pada bagian ini kita memasang dua lapisan pertahanan: validasi payload sebelum data menyentuh database, dan satu format error yang seragam untuk semua kegagalan.

Format Error Seragam #

Setiap kegagalan kini berbentuk amplop yang sama — error berisi kode, pesan, dan opsional detail:

StatuskodePemicu
400VALIDASIPayload melanggar skema
400JSON_TIDAK_VALIDBody bukan JSON yang bisa diurai
400ID_TIDAK_VALID:id bukan 24 karakter heksadesimal
400PAYLOAD_BESARBody melebihi 64 KB
404TUGAS_TIDAK_ADA / ROUTE_TIDAK_ADAResource atau endpoint hilang
409DUPLIKATUnique index menolak kode kembar (E11000)
500KESALAHAN_SERVERGalat tak terduga — satu-satunya 500

Pembagian tanggung jawabnya tegas: 400 berarti klien mengirim hal yang salah, 404 berarti halnya tidak ada, 409 berarti bentrok dengan data yang sudah ada, dan 500 berarti server kami yang bermasalah. Status 401 dan 403 menyusul di Bagian 4.

Validasi Manual Bergaya jsonSchema #

Kita menulis skema sendiri yang bentuk aturannya meniru $jsonSchema dari chapter JSON Schema Validation — wajib seperti required, tipe seperti bsonType, enum dan batas panjang seperti aslinya:

js
// validasi.js — validasi payload manual bergaya $jsonSchema
// Skema mendeklarasikan field yang dikenal beserta aturannya, lalu
// validasiPayload() memeriksa payload, membuang field tak dikenal,
// dan mengisi nilai bawaan. Hasilnya: { valid, galat, nilai }.

const STATUS = ["todo", "doing", "done"];

// Aturan untuk POST /tugas (pembuatan tugas baru).
const SKEMA_TUGAS = {
  kode: { wajib: true, tipe: "string", panjangMin: 3, panjangMaks: 30 },
  judul: { wajib: true, tipe: "string", panjangMin: 3, panjangMaks: 100 },
  deskripsi: { wajib: false, tipe: "string", panjangMaks: 500 },
  status: { wajib: false, tipe: "string", enum: STATUS, bawaan: "todo" },
};

// Aturan untuk PUT /tugas/:id — kode sengaja absen: kode tidak bisa diubah.
const SKEMA_UBAH = {
  judul: { wajib: false, tipe: "string", panjangMin: 3, panjangMaks: 100 },
  deskripsi: { wajib: false, tipe: "string", panjangMaks: 500 },
  status: { wajib: false, tipe: "string", enum: STATUS },
};

function periksaField(nama, nilai, aturan, galat) {
  const sebelum = galat.length;
  if (nilai === undefined || nilai === null) {
    if (aturan.wajib) galat.push({ field: nama, pesan: "field wajib diisi" });
    return galat.length === sebelum;
  }
  if (typeof nilai !== aturan.tipe) {
    galat.push({ field: nama, pesan: "harus bertipe " + aturan.tipe });
    return false;
  }
  if (aturan.panjangMin && nilai.length < aturan.panjangMin) {
    galat.push({ field: nama, pesan: "minimal " + aturan.panjangMin + " karakter" });
  }
  if (aturan.panjangMaks && nilai.length > aturan.panjangMaks) {
    galat.push({ field: nama, pesan: "maksimal " + aturan.panjangMaks + " karakter" });
  }
  if (aturan.enum && !aturan.enum.includes(nilai)) {
    galat.push({
      field: nama,
      pesan: "harus salah satu dari: " + aturan.enum.join(", "),
    });
  }
  return galat.length === sebelum;
}

// Memeriksa payload terhadap skema; field tak dikenal dibuang diam-diam.
function validasiPayload(payload, skema) {
  const galat = [];
  const nilai = {};
  for (const nama of Object.keys(skema)) {
    const aturan = skema[nama];
    const masuk = periksaField(nama, payload[nama], aturan, galat);
    if (masuk && payload[nama] !== undefined && payload[nama] !== null) {
      nilai[nama] = payload[nama];
    } else if (masuk && aturan.bawaan !== undefined) {
      nilai[nama] = aturan.bawaan;
    }
  }
  return { valid: galat.length === 0, galat: galat, nilai: nilai };
}

// PUT wajib mengubah minimal satu field yang dikenal.
function validasiUbah(payload) {
  const hasil = validasiPayload(payload, SKEMA_UBAH);
  if (hasil.valid && Object.keys(hasil.nilai).length === 0) {
    hasil.galat.push({ field: "payload", pesan: "tidak ada field yang dikenal" });
    hasil.valid = false;
  }
  return hasil;
}

// Pembuatan tugas memakai SKEMA_TUGAS penuh.
function validasiTugas(payload) {
  return validasiPayload(payload, SKEMA_TUGAS);
}

// Id MongoDB berupa 24 karakter heksadesimal.
function idValid(id) {
  return typeof id === "string" && /^[0-9a-f]{24}$/.test(id);
}

module.exports = {
  STATUS, SKEMA_TUGAS, SKEMA_UBAH,
  validasiPayload, validasiTugas, validasiUbah, idValid,
};

Empat hal patut dicermati:

  1. periksaField() mengembalikan lolos-tidaknya field itu sendiri — penanda sebelum memastikan galat field lain tidak ikut menular, sehingga nilai hanya berisi field yang sehat.
  2. Field tak dikenal dibuang, bukan ditolak. validasiPayload() hanya menyalin field yang ada di skema; peran atau _id yang diselundupkan klien tidak pernah sampai ke repository. Inilah yang menutup celah mass assignment pada PUT tahap sebelumnya.
  3. SKEMA_UBAH tanpa kode — daftar field yang bisa diubah ditentukan skema, sehingga kode tugas tidak bisa diganti setelah dibuat.
  4. idValid() memeriksa pola 24 karakter heksadesimal sebelum id dikirim ke ObjectId — tanpa gerbang ini, id sembarangan membuat new ObjectId() melempar galat yang berujung 500.

Pemetaan Galat di Server #

server.js menambah class GalatApi untuk galat yang membawa status HTTP sendiri, serta petakanGalat() yang menerjemahkan galat JavaScript menjadi respons:

js server.js — GalatApi dan petakanGalat()
// Galat yang membawa kode status HTTP sendiri.
class GalatApi extends Error {
  constructor(kodeStatus, kode, pesan) {
    super(pesan);
    this.kodeStatus = kodeStatus;
    this.kode = kode;
  }
}

// Menerjemahkan galat JavaScript/driver menjadi respons HTTP.
function petakanGalat(galat) {
  if (galat instanceof GalatApi) {
    return { status: galat.kodeStatus, isi: { kode: galat.kode, pesan: galat.message } };
  }
  if (galat instanceof SyntaxError) {
    return { status: 400, isi: { kode: "JSON_TIDAK_VALID", pesan: "body bukan JSON yang valid" } };
  }
  if (galat.code === 11000) {
    const field = Object.keys(galat.keyValue || {})[0] || "field";
    return {
      status: 409,
      isi: { kode: "DUPLIKAT", pesan: field + " sudah dipakai tugas lain" },
    };
  }
  return {
    status: 500,
    isi: { kode: "KESALAHAN_SERVER", pesan: "terjadi kesalahan tak terduga" },
  };
}

Handler POST kini memanggil validasiTugas(data) dan menolak hasil.valid === false dengan status 400 beserta detail; blok catch di akhir tangani() mengalihkan seluruh galat ke petakanGalat() dan hanya mencetak jejak lengkap ke console saat statusnya 500. Dua pemetaan menarik:

  • SyntaxError → 400: JSON.parse("") melempar SyntaxError, sehingga body kosong atau rusak berhenti dianggap kesalahan server.
  • galat.code === 11000 → 409: galat duplicate key dari driver membawa keyValue berisi field penyebab — { kode: "TG-001" } — yang kita pakai untuk membuat pesan yang menjelaskan field mana yang bentrok.

Menguji Keempat Rambu #

Dengan validasi aktif, uji ulang payload bermasalah dari pembuka bagian ini:

bash
curl -s -X POST http://127.0.0.1:3000/tugas \
  -H "Content-Type: application/json" -d '{"kode":"TG-001","judul":"Menulis dokumen API"}'

curl -s -X POST http://127.0.0.1:3000/tugas -d '{"kode":"TG-100","judul":123}'

curl -s -X POST http://127.0.0.1:3000/tugas -d '{"kode":"TG-101","status":"selesai"}'

Output yang Dihasilkan:

text
{
  "_id": "6ad40d1a78a844804bb30190",
  "kode": "TG-001",
  "judul": "Menulis dokumen API",
  "deskripsi": null,
  "status": "todo",
  "dibuat_pada": "2026-10-18T00:04:42.663Z",
  "diubah_pada": "2026-10-18T00:04:42.663Z"
}
{
  "error": {
    "kode": "VALIDASI",
    "pesan": "payload tidak valid",
    "detail": [
      {
        "field": "judul",
        "pesan": "harus bertipe string"
      }
    ]
  }
}
{
  "error": {
    "kode": "VALIDASI",
    "pesan": "payload tidak valid",
    "detail": [
      {
        "field": "judul",
        "pesan": "field wajib diisi"
      },
      {
        "field": "status",
        "pesan": "harus salah satu dari: todo, doing, done"
      }
    ]
  }
}

Payload ketiga menunjukkan detail berisi dua galat sekaligus — klien tahu persis field mana yang harus diperbaiki tanpa perlu bolak-balik. Lanjut ke tiga kasus lain:

bash
curl -s -X POST http://127.0.0.1:3000/tugas

curl -s -X POST http://127.0.0.1:3000/tugas -d '{"kode":"TG-001","judul":"Duplikat kode"}'

curl -s http://127.0.0.1:3000/tugas/bukan-id

Output yang Dihasilkan:

text
{
  "error": {
    "kode": "JSON_TIDAK_VALID",
    "pesan": "body bukan JSON yang valid"
  }
}
{
  "error": {
    "kode": "DUPLIKAT",
    "pesan": "kode sudah dipakai tugas lain"
  }
}
{
  "error": {
    "kode": "ID_TIDAK_VALID",
    "pesan": "id harus 24 karakter heksadesimal"
  }
}

Body kosong kini 400, kode kembar menjadi 409 DUPLIKAT (pemetaan E11000 bekerja), dan id tidak berbentuk ditolak sebelum menyentuh ObjectId. Terakhir, PUT yang hanya membawa field asing — termasuk upaya menimpa _id — ditolak karena skema tidak mengenalinya:

bash
curl -s -X PUT http://127.0.0.1:3000/tugas/6ad40d1a78a844804bb30190 \
  -H "Content-Type: application/json" \
  -d '{"prioritas":"tinggi","_id":"000000000000000000000000"}'

Output yang Dihasilkan:

text
{
  "error": {
    "kode": "VALIDASI",
    "pesan": "payload tidak valid",
    "detail": [
      {
        "field": "payload",
        "pesan": "tidak ada field yang dikenal"
      }
    ]
  }
}

Apa Berikutnya #

Data yang masuk kini bersih dan kegagalan berbicara dengan satu bahasa. Satu celah besar masih terbuka: siapa pun yang menemukan alamat server bisa menulis dan menghapus tugas. Bagian 4 memasang pintu berupa API key dan peran pengguna.