Lapisan data dari Bagian 1 sudah bisa dipanggil sebagai function biasa, tetapi pengguna blog tidak akan mengimpor modul kita — mereka mengirim permintaan HTTP. Pada bagian ini kita membangun lapisan HTTP di src/server.js: satu modul yang menerjemahkan permintaan menjadi panggilan fungsi database, lalu menjawabnya dengan JSON dan status code yang tepat. Kita tetap memakai node:http murni tanpa framework, seperti pola yang kita latih pada Section REST API, supaya setiap keputusan routing terlihat jelas.
Pabrik Server Terpisah dari Database #
Kunci arsitektur proyek ini ada di satu keputusan kecil: server.js tidak pernah membuka database sendiri. Modul ini mengekspor pabrik buatServer(db) yang menerima database dari luar, seperti yang kita kenal dari Chapter Dependency Injection:
export function buatServer(db) {
return http.createServer(async (req, res) => {
try {
await rutekan(req, res, db);
} catch (e) {
if (e instanceof SyntaxError) {
kirim(res, 400, { galat: 'Body bukan JSON valid' });
} else if (e instanceof GalatBody) {
kirim(res, 400, { galat: e.message });
} else {
kirim(res, 500, { galat: 'Kesalahan internal server' });
}
}
});
}Mari kita bedah fungsinya:
buatServer(db)mengembalikanhttp.Servertanpa mendengarkan port. Pemanggil yang memutuskan kapan memanggillisten()— produksi lewatsrc/cli.js, test lewatlisten(0)untuk port acak. Satu pabrik, dua konteks.- Listener dibuat
asyncdan seluruh routing dibungkustry/catch. Handler kita memakaiawaituntuk membaca body, jadi error bisa muncul sebagai rejected promise;try/catchmemastikan setiap kegagalan tetap dijawab dengan JSON, bukan koneksi yang menggantung. - Tiga jenis kegagalan dipetakan ke tiga jawaban.
SyntaxErrordariJSON.parsedanGalatBody(body valid secara JSON tetapi bukan object) menjadi 400; error lain menjadi 500 tanpa membocorkan detail internal ke pengguna.
Routing Berbasis Segmen #
Sebelum memilih handler, kita perlu mengurai path. Kelas URL melakukan parsing itu, lalu path dipecah menjadi segmen:
async function rutekan(req, res, db) {
const url = new URL(req.url, `http://localhost`);
const segmen = url.pathname.split('/').filter(Boolean);
if (segmen[0] === 'artikel' && segmen.length === 1) {
if (req.method === 'GET') {
return kirim(res, 200, db.daftarArtikel(url.searchParams.get('q')));
}
if (req.method === 'POST') {
const body = await bacaJson(req);
const galat = wajibString(body, 'judul') || wajibString(body, 'isi');
if (galat) return kirim(res, 400, { galat });
return kirim(res, 201, db.tambahArtikel(body.judul, body.isi));
}
}Tiga baris pertama memikul banyak pekerjaan:
- Konstruktor
URLdengan base:req.urlberupa path relatif seperti/artikel?q=sqlite, sedangkan konstruktorURLmembutuhkan URL lengkap. Basehttp://localhosthanyalah penopang — host-nya tidak pernah dipakai untuk apa pun. split('/').filter(Boolean):/artikel/1menjadi['artikel', '1'].filter(Boolean)sekaligus menormalkan slash berlebih, sehingga/artikel/dan/artikeldiperlakukan sama.- Kombinasi
segmen[0],segmen.length, danreq.methodmenjadi alamat setiap endpoint. Query?q=diambil lewatsearchParams.get()yang sudah otomatis ter-decode.
Segmen id sendiri divalidasi oleh helper kecil sebelum dipakai:
function bacaId(teks) {
if (!/^\d+$/.test(teks)) return null;
const id = Number(teks);
return id > 0 ? id : null;
}
function wajibString(objek, nama) {
if (objek[nama] === undefined) return `Field ${nama} wajib ada`;
if (typeof objek[nama] !== 'string' || objek[nama].trim() === '') {
return `Field ${nama} wajib string tidak kosong`;
}
return null;
}bacaId hanya menerima deretan digit positif — path seperti /artikel/abc atau /artikel/1.5 ditolak dengan 400 sebelum menyentuh database. wajibString mengembalikan pesan galat atau null, sehingga pemanggil cukup memeriksa satu nilai untuk memutuskan 400.
Respons JSON dan Pembacaan Body #
Dua helper menangani seluruh lalu lintas body. kirim menulis respons, bacaJson mengumpulkan dan mengurai permintaan:
function kirim(res, status, data) {
if (data === undefined) {
res.writeHead(status).end();
return;
}
const body = JSON.stringify(data);
res.writeHead(status, {
'content-type': 'application/json; charset=utf-8',
'content-length': Buffer.byteLength(body),
});
res.end(body);
}
async function bacaJson(req) {
let mentah = '';
for await (const potongan of req) mentah += potongan;
if (mentah.trim() === '') return {};
const hasil = JSON.parse(mentah);
if (hasil === null || typeof hasil !== 'object' || Array.isArray(hasil)) {
throw new GalatBody('Body harus berupa object JSON');
}
return hasil;
}data === undefinedberarti respons tanpa body — dipakai untuk status 204 setelah penghapusan sukses. Selain itu body di-JSON.stringify, lengkap dengancontent-typedancontent-lengthdihitung dariBuffer.byteLengthagar panjang dihitung dalam byte, bukan jumlah karakter.for await (const potongan of req)membaca stream permintaan secara bertahap; pola ini berasal dari Chapter Request Body. Body kosong dianggap{}agarPATCHtanpa isi jatuh ke validasi, bukan ke error parsing.- Hasil parsing diperiksa bentuknya:
null, array, dan nilai primitif ditolak karena handler kita mengharapkan object — pelindung terakhir sebelum field diakses.
Empat Operasi CRUD Artikel #
Rute dengan dua segmen menangani operasi pada satu artikel. Pola jawabannya konsisten: hasil dari lapisan data menentukan status code:
if (req.method === 'PATCH') {
const body = await bacaJson(req);
const perubahan = {};
if (body.judul !== undefined) perubahan.judul = body.judul;
if (body.isi !== undefined) perubahan.isi = body.isi;
if (Object.keys(perubahan).length === 0) {
return kirim(res, 400, { galat: 'Sertakan field judul atau isi' });
}
// PATCH bersifat sebagian: hanya field yang dikirim yang divalidasi.
for (const kolom of Object.keys(perubahan)) {
const galat = wajibString(perubahan, kolom);
if (galat) return kirim(res, 400, { galat });
}
const artikel = db.ubahArtikel(id, perubahan);
return artikel
? kirim(res, 200, artikel)
: kirim(res, 404, { galat: 'Artikel tidak ditemukan' });
}
if (req.method === 'DELETE') {
return db.hapusArtikel(id)
? kirim(res, 204)
: kirim(res, 404, { galat: 'Artikel tidak ditemukan' });
}Perhatikan alur PATCH: hanya field yang benar-benar dikirim yang disalin ke perubahan, divalidasi satu per satu, lalu diteruskan ke db.ubahArtikel yang kita bedah di Bagian 1. Ketika artikel tidak ditemukan, lapisan data mengembalikan undefined (untuk ubah) atau false (untuk hapus), dan handler menerjemahkannya menjadi 404. Kita memilih PATCH alih-alih PUT karena endpoint ini mengubah sebagian field; menambahkan PUT yang mewajibkan judul dan isi lengkap menjadi latihan yang bagus.
Mencoba Endpoint Artikel #
Berkas coba-artikel.mjs pada folder bagian ini menyalakan server di port acak, menjalankan satu sesi lengkap lewat fetch, lalu menutup semuanya sebelum proses berakhir:
// Sesi HTTP untuk endpoint artikel: server di port acak, lalu ditutup.
import { buatDb, WAKTU_DEMO } from '../../src/db.js';
import { buatServer } from '../../src/server.js';
const db = buatDb(':memory:', { waktu: () => WAKTU_DEMO });
const server = buatServer(db);
server.listen(0, '127.0.0.1', async () => {
const url = `http://127.0.0.1:${server.address().port}`;
async function panggil(metode, path, body) {
const r = await fetch(url + path, {
method: metode,
headers: body !== undefined ? { 'content-type': 'application/json' } : {},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const teks = await r.text();
console.log(
`${metode} ${path} -> ${r.status}${teks === '' ? '' : ' ' + teks}`
);
}
await panggil('POST', '/artikel', {
judul: 'Belajar node:sqlite',
isi: 'SQLite kini dibawa langsung oleh Node.js.',
});
await panggil('POST', '/artikel', {
judul: 'REST API tanpa framework',
isi: 'Untuk API kecil, node:http sudah cukup.',
});
await panggil('GET', '/artikel');
await panggil('GET', '/artikel?q=framework');
await panggil('GET', '/artikel/1');
await panggil('PATCH', '/artikel/1', {
judul: 'Belajar node:sqlite dan node:http',
});
await panggil('POST', '/artikel', { judul: '', isi: 'Judul kosong.' });
await panggil('PATCH', '/artikel/1', {});
await panggil('GET', '/artikel/999');
await panggil('DELETE', '/artikel/2');
await panggil('DELETE', '/artikel/2');
server.closeAllConnections();
server.close(() => db.tutup());
});Output yang Dihasilkan:
POST /artikel -> 201 {"id":1,"judul":"Belajar node:sqlite","isi":"SQLite kini dibawa langsung oleh Node.js.","created_at":"2026-01-15T08:00:00.000Z"}
POST /artikel -> 201 {"id":2,"judul":"REST API tanpa framework","isi":"Untuk API kecil, node:http sudah cukup.","created_at":"2026-01-15T08:00:00.000Z"}
GET /artikel -> 200 [{"id":1,"judul":"Belajar node:sqlite","isi":"SQLite kini dibawa langsung oleh Node.js.","created_at":"2026-01-15T08:00:00.000Z"},{"id":2,"judul":"REST API tanpa framework","isi":"Untuk API kecil, node:http sudah cukup.","created_at":"2026-01-15T08:00:00.000Z"}]
GET /artikel?q=framework -> 200 [{"id":2,"judul":"REST API tanpa framework","isi":"Untuk API kecil, node:http sudah cukup.","created_at":"2026-01-15T08:00:00.000Z"}]
GET /artikel/1 -> 200 {"id":1,"judul":"Belajar node:sqlite","isi":"SQLite kini dibawa langsung oleh Node.js.","created_at":"2026-01-15T08:00:00.000Z","komentar":[]}
PATCH /artikel/1 -> 200 {"id":1,"judul":"Belajar node:sqlite dan node:http","isi":"SQLite kini dibawa langsung oleh Node.js.","created_at":"2026-01-15T08:00:00.000Z"}
POST /artikel -> 400 {"galat":"Field judul wajib string tidak kosong"}
PATCH /artikel/1 -> 400 {"galat":"Sertakan field judul atau isi"}
GET /artikel/999 -> 404 {"galat":"Artikel tidak ditemukan"}
DELETE /artikel/2 -> 204
DELETE /artikel/2 -> 404 {"galat":"Artikel tidak ditemukan"}Sesi di atas memperlihatkan seluruh spektrum status code: 201 untuk dua artikel baru, 200 untuk daftar, detail, dan hasil PATCH, 400 untuk judul kosong maupun body PATCH tanpa field, 404 untuk id yang tidak ada, dan 204 untuk penghapusan yang berhasil — diikuti 404 pada percobaan hapus kedua.
CRUD artikel kini utuh. Tetapi blog yang hidup bukan hanya tulisan — pembaca ingin menanggapi. Kita butuh tabel kedua yang sudah menunggu di skema, relasi foreign key yang menjaganya, dan endpoint bersarang untuk mengelolanya. Semuanya menanti di Bagian 3.
Referensi resmi: node:http , http.createServer , dan URL di MDN .