Framework web seperti Express memberi kita router siap pakai — tetapi kali ini kita menulis sendiri di atas node:http. Tujuannya bukan menolak framework, melainkan melihat mekanisme yang biasanya disembunyikan: bagaimana satu permintaan HTTP dipilah berdasarkan method dan path, bagaimana body JSON dirakit dari potongan event, dan bagaimana hasil query MongoDB dikirim kembali sebagai respons. Pada bagian ini kita bangun kedua file inti: repository tugas.js dan server server.js tahap pertama.

Peta Endpoint #

Lima endpoint melayani resource tugas:

Method dan PathPeranStatus sukses
GET /tugasDaftar tugas, filter status, pagination halaman/batas200
GET /tugas/:idSatu tugas berdasarkan _id200
POST /tugasTugas baru (kode, judul, deskripsi, status)201
PUT /tugas/:idUbah field tugas200
DELETE /tugas/:idHapus tugas200

Field status mengikuti alur kerja tim: todo → doing → done.

Repository Tugas #

Modul ini merangkum seluruh akses data — handler HTTP tidak pernah memanggil collection() secara langsung, sesuai pola chapter Repository Pattern:

js
// tugas.js — repository: seluruh akses data collection "tugas"
// Setiap fungsi menerima handle db sehingga mudah diuji tanpa server HTTP.

const { ObjectId } = require("mongodb");

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

// Index dibuat sekali saat server mulai; createIndex bersifat idempoten.
async function siapkanIndex(db) {
  const tugas = db.collection("tugas");
  await tugas.createIndex({ kode: 1 }, { unique: true });
  await tugas.createIndex({ status: 1, dibuat_pada: -1 });
}

// data sudah tervalidasi dan bersih sebelum masuk ke sini.
async function buatTugas(db, data) {
  const sekarang = new Date();
  const tugas = {
    kode: data.kode,
    judul: data.judul,
    deskripsi: data.deskripsi || null,
    status: data.status || "todo",
    dibuat_pada: sekarang,
    diubah_pada: sekarang,
  };
  const hasil = await db.collection("tugas").insertOne(tugas);
  return { _id: hasil.insertedId, ...tugas };
}

// filter opsional: { status, halaman, batas }
async function daftarTugas(db, filter) {
  const status = filter.status && STATUS.includes(filter.status)
    ? filter.status : null;
  const batas = Math.min(Number(filter.batas) || 10, BATAS_HALAMAN);
  const halaman = Math.max(Number(filter.halaman) || 1, 1);

  const kriteria = status ? { status: status } : {};
  const kursor = db.collection("tugas")
    .find(kriteria)
    .sort({ dibuat_pada: -1 })
    .skip((halaman - 1) * batas)
    .limit(batas);

  const [total, data] = await Promise.all([
    db.collection("tugas").countDocuments(kriteria),
    kursor.toArray(),
  ]);
  return { total, halaman, batas, data };
}

// Mengembalikan document atau null bila tidak ditemukan.
async function cariTugas(db, id) {
  if (!ObjectId.isValid(id)) return null;
  return db.collection("tugas").findOne({ _id: new ObjectId(id) });
}

// Mengembalikan document hasil pembaruan atau null bila id tidak ada.
async function ubahTugas(db, id, perubahan) {
  if (!ObjectId.isValid(id)) return null;
  const hasil = await db.collection("tugas").findOneAndUpdate(
    { _id: new ObjectId(id) },
    { $set: { ...perubahan, diubah_pada: new Date() } },
    { returnDocument: "after" }
  );
  return hasil || null;
}

// Mengembalikan true bila ada document yang terhapus.
async function hapusTugas(db, id) {
  if (!ObjectId.isValid(id)) return false;
  const hasil = await db.collection("tugas").deleteOne({ _id: new ObjectId(id) });
  return hasil.deletedCount === 1;
}

module.exports = {
  STATUS, siapkanIndex, buatTugas, daftarTugas,
  cariTugas, ubahTugas, hapusTugas,
};

Tiga keputusan penting di dalamnya:

  1. siapkanIndex() memasang dua index: { kode: 1 } unik sebagai penjaga integritas di sisi server (dua tugas tidak mungkin berbagi kode), dan { status: 1, dibuat_pada: -1 } compound yang persis mengikuti query daftar — filter status equality di depan, sort dibuat_pada di belakang, sesuai aturan chapter Compound Index.
  2. buatTugas() menerima data yang sudah bersih, menempel stempel waktu dibuat_pada/diubah_pada, lalu mengembalikan document hasil rakitan { _id: hasil.insertedId, ...tugas } — insertOne() hanya mengembalikan id, bukan document utuh.
  3. daftarTugas() menjalankan countDocuments() dan pengambilan data lewat Promise.all() sekaligus, lalu membatas batas maksimal 50 agar klien tidak meminta seluruh collection dalam satu permintaan.

Server HTTP Tahap CRUD #

js server.js — tahap 1: routing dan CRUD
// server.js — REST API manajemen tugas (node:http, tanpa framework)
// Tahap 1: routing + CRUD. Jalankan:
//   node src/server.js
// Variabel lingkungan: PORT (default 3000), MONGODB_URI, DB_NAME.

const http = require("node:http");
const { ambilDb } = require("./db");
const tugas = require("./tugas");

const PORT = Number(process.env.PORT || 3000);

function kirimJson(res, kodeStatus, payload) {
  const isi = JSON.stringify(payload, null, 2);
  res.writeHead(kodeStatus, {
    "Content-Type": "application/json; charset=utf-8",
  });
  res.end(isi);
}

function bacaBody(req) {
  return new Promise(function (selesai, gagal) {
    let isi = "";
    req.on("data", function (potongan) {
      isi += potongan;
      if (isi.length > 64 * 1024) {
        gagal(new Error("payload terlalu besar"));
        req.destroy();
      }
    });
    req.on("end", function () { selesai(isi); });
    req.on("error", gagal);
  });
}

async function tangani(req, res) {
  const url = new URL(req.url, "http://localhost");
  const bagian = url.pathname.split("/").filter(Boolean);

  try {
    const db = await ambilDb();

    if (req.method === "GET" && bagian[0] === "tugas" && bagian.length === 1) {
      const hasil = await tugas.daftarTugas(db, {
        status: url.searchParams.get("status"),
        halaman: url.searchParams.get("halaman"),
        batas: url.searchParams.get("batas"),
      });
      return kirimJson(res, 200, hasil);
    }

    if (req.method === "GET" && bagian[0] === "tugas" && bagian.length === 2) {
      const satu = await tugas.cariTugas(db, bagian[1]);
      if (!satu) {
        return kirimJson(res, 404, {
          error: { kode: "TUGAS_TIDAK_ADA", pesan: "tugas tidak ditemukan" },
        });
      }
      return kirimJson(res, 200, satu);
    }

    if (req.method === "POST" && bagian[0] === "tugas" && bagian.length === 1) {
      const isi = await bacaBody(req);
      const data = JSON.parse(isi);
      const baru = await tugas.buatTugas(db, data);
      return kirimJson(res, 201, baru);
    }

    if (req.method === "PUT" && bagian[0] === "tugas" && bagian.length === 2) {
      const isi = await bacaBody(req);
      const data = JSON.parse(isi);
      const ubah = await tugas.ubahTugas(db, bagian[1], data);
      if (!ubah) {
        return kirimJson(res, 404, {
          error: { kode: "TUGAS_TIDAK_ADA", pesan: "tugas tidak ditemukan" },
        });
      }
      return kirimJson(res, 200, ubah);
    }

    if (req.method === "DELETE" && bagian[0] === "tugas" && bagian.length === 2) {
      const terhapus = await tugas.hapusTugas(db, bagian[1]);
      if (!terhapus) {
        return kirimJson(res, 404, {
          error: { kode: "TUGAS_TIDAK_ADA", pesan: "tugas tidak ditemukan" },
        });
      }
      return kirimJson(res, 200, { terhapus: true });
    }

    return kirimJson(res, 404, {
      error: { kode: "ROUTE_TIDAK_ADA", pesan: "endpoint tidak dikenal" },
    });
  } catch (galat) {
    console.error(galat);
    return kirimJson(res, 500, {
      error: { kode: "KESALAHAN_SERVER", pesan: "terjadi kesalahan tak terduga" },
    });
  }
}

function buatServer() {
  return http.createServer(tangani);
}

if (require.main === module) {
  buatServer().listen(PORT, "127.0.0.1", async function () {
    try {
      const db = await ambilDb();
      await tugas.siapkanIndex(db);
      console.log("API manajemen tugas berjalan di http://127.0.0.1:" + PORT);
    } catch (galat) {
      console.error("gagal mulai:", galat.message);
      process.exit(1);
    }
  });
}

module.exports = { buatServer, tangani };

Cara kerja routing ini sederhana: url.pathname dipecah menjadi bagian (/tugas/abc menjadi ["tugas", "abc"]), lalu setiap blok if mencocokkan kombinasi method dan bentuk path. Empat bagian penting:

  1. kirimJson() — satu fungsi pengirim respons; payload dirakit dengan JSON.stringify(..., null, 2) agar keluaran terbaca saat uji manual.
  2. bacaBody() — body permintaan datang sebagai potongan event data; promise ini merakitnya menjadi string utuh dengan pengaman ukuran 64 KB sebelum di-JSON.parse oleh handler.
  3. Blok try/catch — seluruh handler berada di dalamnya; galat apa pun yang lolos diterjemahkan menjadi respons 500 sementara jejak lengkapnya tercetak di console server.
  4. require.main === module — saat dieksekusi langsung, server membuat index lalu mendengarkan port; saat di-require (misalnya oleh suite tes), fungsi buatServer() tersedia tanpa otomatis mendengarkan port.

Mencicipi Endpoint #

Nyalakan server, lalu buat tiga tugas:

bash
node src/server.js
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","deskripsi":"Rancang endpoint awal"}'

Output yang Dihasilkan:

text
{
  "_id": "6ad40d2fe02e3fe0624a59bd",
  "kode": "TG-001",
  "judul": "Menulis dokumen API",
  "deskripsi": "Rancang endpoint awal",
  "status": "todo",
  "dibuat_pada": "2026-10-18T00:05:03.678Z",
  "diubah_pada": "2026-10-18T00:05:03.678Z"
}

Dua POST berikutnya membuat tugas TG-002 (judul “Menambahkan pengujian”) dan TG-003 (judul “Memperbarui dependensi”) dengan bentuk respons yang sama. Ambil daftarnya:

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

Output yang Dihasilkan:

text
{
  "total": 3,
  "halaman": 1,
  "batas": 10,
  "data": [
    {
      "_id": "6ad40d2fe02e3fe0624a59bf",
      "kode": "TG-003",
      "judul": "Memperbarui dependensi",
      "deskripsi": null,
      "status": "todo",
      "dibuat_pada": "2026-10-18T00:05:03.699Z",
      "diubah_pada": "2026-10-18T00:05:03.699Z"
    },
    {
      "_id": "6ad40d2fe02e3fe0624a59be",
      "kode": "TG-002",
      "judul": "Menambahkan pengujian",
      "deskripsi": null,
      "status": "todo",
      "dibuat_pada": "2026-10-18T00:05:03.691Z",
      "diubah_pada": "2026-10-18T00:05:03.691Z"
    },
    {
      "_id": "6ad40d2fe02e3fe0624a59bd",
      "kode": "TG-001",
      "judul": "Menulis dokumen API",
      "deskripsi": "Rancang endpoint awal",
      "status": "todo",
      "dibuat_pada": "2026-10-18T00:05:03.678Z",
      "diubah_pada": "2026-10-18T00:05:03.678Z"
    }
  ]
}

Filter dan pagination bekerja sebagai query string — batas=2 memangkas data menjadi dua entri walau total tetap melaporkan 3:

bash
curl -s "http://127.0.0.1:3000/tugas?status=todo&halaman=1&batas=2"

Output yang Dihasilkan:

text
{
  "total": 3,
  "halaman": 1,
  "batas": 2,
  "data": [
    {
      "_id": "6ad40d2fe02e3fe0624a59bf",
      "kode": "TG-003",
      "judul": "Memperbarui dependensi",
      "deskripsi": null,
      "status": "todo",
      "dibuat_pada": "2026-10-18T00:05:03.699Z",
      "diubah_pada": "2026-10-18T00:05:03.699Z"
    },
    {
      "_id": "6ad40d2fe02e3fe0624a59be",
      "kode": "TG-002",
      "judul": "Menambahkan pengujian",
      "deskripsi": null,
      "status": "todo",
      "dibuat_pada": "2026-10-18T00:05:03.691Z",
      "diubah_pada": "2026-10-18T00:05:03.691Z"
    }
  ]
}

Pindahkan TG-001 ke papan kerja, lalu hapus:

bash
curl -s -X PUT http://127.0.0.1:3000/tugas/6ad40d2fe02e3fe0624a59bd \
  -H "Content-Type: application/json" -d '{"status":"doing"}'

curl -s -X DELETE http://127.0.0.1:3000/tugas/6ad40d2fe02e3fe0624a59bd

Output yang Dihasilkan:

text
{
  "_id": "6ad40d2fe02e3fe0624a59bd",
  "kode": "TG-001",
  "judul": "Menulis dokumen API",
  "deskripsi": "Rancang endpoint awal",
  "status": "doing",
  "dibuat_pada": "2026-10-18T00:05:03.678Z",
  "diubah_pada": "2026-10-18T00:05:08.447Z"
}
{
  "terhapus": true
}

Perhatikan diubah_pada bergeser lebih baru dari dibuat_pada — jejak pembaruan dari findOneAndUpdate. Permintaan berikutnya untuk id yang sama, maupun path yang tidak dikenal, dijawab 404 dengan format yang sama:

bash
curl -s http://127.0.0.1:3000/tugas/6ad40d2fe02e3fe0624a59bd
curl -s http://127.0.0.1:3000/pengguna

Output yang Dihasilkan:

text
{
  "error": {
    "kode": "TUGAS_TIDAK_ADA",
    "pesan": "tugas tidak ditemukan"
  }
}
{
  "error": {
    "kode": "ROUTE_TIDAK_ADA",
    "pesan": "endpoint tidak dikenal"
  }
}

Apa Berikutnya #

CRUD berjalan, tetapi server ini masih mempercayai klien sepenuhnya: payload kosong dijawab 500, judul berupa angka lolos tersimpan, dan kode kembar menghasilkan error server. Bagian 3 menutup semua celah tersebut dengan lapisan validasi dan format error yang seragam.