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 Path | Peran | Status sukses |
|---|---|---|
GET /tugas | Daftar tugas, filter status, pagination halaman/batas | 200 |
GET /tugas/:id | Satu tugas berdasarkan _id | 200 |
POST /tugas | Tugas baru (kode, judul, deskripsi, status) | 201 |
PUT /tugas/:id | Ubah field tugas | 200 |
DELETE /tugas/:id | Hapus tugas | 200 |
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:
// 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:
siapkanIndex()memasang dua index:{ kode: 1 }unik sebagai penjaga integritas di sisi server (dua tugas tidak mungkin berbagikode), dan{ status: 1, dibuat_pada: -1 }compound yang persis mengikuti query daftar — filterstatusequality di depan, sortdibuat_padadi belakang, sesuai aturan chapter Compound Index.buatTugas()menerima data yang sudah bersih, menempel stempel waktudibuat_pada/diubah_pada, lalu mengembalikan document hasil rakitan{ _id: hasil.insertedId, ...tugas }—insertOne()hanya mengembalikan id, bukan document utuh.daftarTugas()menjalankancountDocuments()dan pengambilan data lewatPromise.all()sekaligus, lalu membatasbatasmaksimal 50 agar klien tidak meminta seluruh collection dalam satu permintaan.
Server HTTP Tahap 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:
kirimJson()— satu fungsi pengirim respons; payload dirakit denganJSON.stringify(..., null, 2)agar keluaran terbaca saat uji manual.bacaBody()— body permintaan datang sebagai potongan eventdata; promise ini merakitnya menjadi string utuh dengan pengaman ukuran 64 KB sebelum di-JSON.parseoleh handler.- Blok
try/catch— seluruh handler berada di dalamnya; galat apa pun yang lolos diterjemahkan menjadi respons 500 sementara jejak lengkapnya tercetak di console server. require.main === module— saat dieksekusi langsung, server membuat index lalu mendengarkan port; saat di-require(misalnya oleh suite tes), fungsibuatServer()tersedia tanpa otomatis mendengarkan port.
Mencicipi Endpoint #
Nyalakan server, lalu buat tiga tugas:
node src/server.jscurl -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:
{
"_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:
curl -s http://127.0.0.1:3000/tugasOutput yang Dihasilkan:
{
"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:
curl -s "http://127.0.0.1:3000/tugas?status=todo&halaman=1&batas=2"Output yang Dihasilkan:
{
"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:
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/6ad40d2fe02e3fe0624a59bdOutput yang Dihasilkan:
{
"_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:
curl -s http://127.0.0.1:3000/tugas/6ad40d2fe02e3fe0624a59bd
curl -s http://127.0.0.1:3000/penggunaOutput yang Dihasilkan:
{
"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.