JSON API secara manual

Penguraian, validasi, dan serialisasi respons JSON tanpa framework.

Daftar isi

JSON API secara manual berarti mengelola tiga tahap pemrosesan JSON tanpa framework: mengurai body permintaan menjadi objek JavaScript, memvalidasi isi objek tersebut terhadap aturan bisnis, serta menyerialisasikan respons kembali menjadi string JSON dengan header yang benar. Modul http tidak menyediakan parser JSON otomatis dan tidak menyediakan validator schema, sehingga ketiga tahap dilakukan dengan JSON.parse, kode validasi manual, dan JSON.stringify. Setelah membaca topik ini, pembaca dapat membangun endpoint JSON yang menangani body tidak valid, menolak masukan yang tidak sesuai aturan, dan mengembalikan respons dengan content-type serta content-length yang benar.

Membaca dan mengurai body JSON#

Penguraian body JSON dimulai dengan membaca stream req sampai peristiwa end, kemudian meneruskan string hasilnya ke JSON.parse. Fungsi JSON.parse melempar SyntaxError bila string bukan JSON valid, sehingga pemanggilan wajib dibungkus try/catch dan error diterjemahkan ke respons 400 Bad Request dengan body JSON yang menjelaskan masalahnya. Fungsi pembantu json(res, status, payload) memusatkan serialisasi respons, sehingga seluruh titik keluar mengeluarkan JSON dengan header yang konsisten.

js api-parse.js
const http = require('node:http');

function bacaBody(req) {
  return new Promise((resolve, reject) => {
    const potongan = [];
    req.on('data', (chunk) => potongan.push(chunk));
    req.on('end', () => resolve(Buffer.concat(potongan).toString('utf8')));
    req.on('error', reject);
  });
}

function json(res, status, payload) {
  const body = JSON.stringify(payload);
  res.writeHead(status, {
    'content-type': 'application/json; charset=utf-8',
    'content-length': Buffer.byteLength(body),
  });
  res.end(body);
}

const server = http.createServer(async (req, res) => {
  const url = new URL(req.url, `http://${req.headers.host}`);
  if (url.pathname !== '/catatan' || req.method !== 'POST') {
    json(res, 404, { error: 'rute tidak ditemukan' });
    return;
  }
  const mentah = await bacaBody(req);
  let data;
  try {
    data = JSON.parse(mentah);
  } catch (err) {
    json(res, 400, { error: 'bukan JSON valid', detail: err.message });
    return;
  }
  json(res, 201, { diterima: true, data });
});

server.listen(0, '127.0.0.1', async () => {
  const { port } = server.address();
  const uji = [
    ['{"judul":"belajar"}', 'application/json'],
    ['{judul tidak valid}', 'application/json'],
    ['', 'application/json'],
  ];
  for (const [body, tipe] of uji) {
    const r = await fetch(`http://127.0.0.1:${port}/catatan`, {
      method: 'POST',
      headers: { 'content-type': tipe },
      body,
    });
    console.log(`body=${JSON.stringify(body)} -> ${r.status} ${await r.text()}`);
  }
  server.close();
});
text Keluaran
body="{\"judul\":\"belajar\"}" -> 201 {"diterima":true,"data":{"judul":"belajar"}}
body="{judul tidak valid}" -> 400 {"error":"bukan JSON valid","detail":"Expected property name or '}' in JSON at position 1 (line 1 column 2)"}
body="" -> 400 {"error":"bukan JSON valid","detail":"Unexpected end of JSON input"}

Penguraian JSON.parse('') menghasilkan error Unexpected end of JSON input, sehingga body kosong harus ditangani sebagai kesalahan masukan, bukan diabaikan. Pesan error dari SyntaxError mengandung posisi karakter yang gagal diurai, dan pesan ini berguna untuk pengembangan. Pada produksi, pesan detail sebaiknya tidak diteruskan ke klien publik karena dapat membocorkan struktur internal parser, sehingga pesan diganti dengan string umum seperti “bukan JSON valid”.

Validasi masukan secara manual#

Validasi adalah pemeriksaan isi objek hasil parsing terhadap aturan bisnis, dilakukan setelah JSON.parse berhasil. Validasi manual memeriksa tipe setiap field dengan typeof, memeriksa nilai kosong dengan pembandingan string, serta memeriksa rentang angka dengan pembandingan numerik. Kesalahan dikumpulkan ke dalam deret agar satu respons dapat memuat seluruh masalah sekaligus, dan bila deret tidak kosong respons 422 Unprocessable Entity dikembalikan dengan daftar kesalahan.

js api-validasi.js
const http = require('node:http');

function bacaBody(req) {
  return new Promise((resolve, reject) => {
    const potongan = [];
    req.on('data', (chunk) => potongan.push(chunk));
    req.on('end', () => resolve(Buffer.concat(potongan).toString('utf8')));
    req.on('error', reject);
  });
}

function json(res, status, payload) {
  const body = JSON.stringify(payload);
  res.writeHead(status, {
    'content-type': 'application/json; charset=utf-8',
    'content-length': Buffer.byteLength(body),
  });
  res.end(body);
}

function validasiCatatan(data) {
  const kesalahan = [];
  if (typeof data !== 'object' || data === null || Array.isArray(data)) {
    kesalahan.push('data harus objek JSON');
    return kesalahan;
  }
  if (typeof data.judul !== 'string') {
    kesalahan.push('judul wajib string');
  } else if (data.judul.trim() === '') {
    kesalahan.push('judul tidak boleh kosong');
  }
  if (data.isi !== undefined && typeof data.isi !== 'string') {
    kesalahan.push('isi jika ada harus string');
  }
  if (data.skor !== undefined && (typeof data.skor !== 'number' || data.skor < 0 || data.skor > 10)) {
    kesalahan.push('skor jika ada harus angka 0 sampai 10');
  }
  return kesalahan;
}

const server = http.createServer(async (req, res) => {
  const url = new URL(req.url, `http://${req.headers.host}`);
  if (url.pathname !== '/catatan' || req.method !== 'POST') {
    json(res, 404, { error: 'rute tidak ditemukan' });
    return;
  }
  let data;
  try {
    data = JSON.parse(await bacaBody(req));
  } catch (err) {
    json(res, 400, { error: 'bukan JSON valid' });
    return;
  }
  const kesalahan = validasiCatatan(data);
  if (kesalahan.length > 0) {
    json(res, 422, { error: 'validasi gagal', detail: kesalahan });
    return;
  }
  json(res, 201, { diterima: true, judul: data.judul });
});

server.listen(0, '127.0.0.1', async () => {
  const { port } = server.address();
  const uji = [
    '{"judul":"belajar","skor":8}',
    '{"judul":"","skor":15}',
    '{"judul":"ok","isi":42}',
    '[]',
  ];
  for (const body of uji) {
    const r = await fetch(`http://127.0.0.1:${port}/catatan`, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body,
    });
    console.log(`body=${body} -> ${r.status} ${await r.text()}`);
  }
  server.close();
});
text Keluaran
body={"judul":"belajar","skor":8} -> 201 {"diterima":true,"judul":"belajar"}
body={"judul":"","skor":15} -> 422 {"error":"validasi gagal","detail":["judul tidak boleh kosong","skor jika ada harus angka 0 sampai 10"]}
body={"judul":"ok","isi":42} -> 422 {"error":"validasi gagal","detail":["isi jika ada harus string"]}
body=[] -> 422 {"error":"validasi gagal","detail":["data harus objek JSON"]}

Pengujian di atas menunjukkan dua kesalahan ditangkap sekaligus pada kasus judul kosong dan skor melampaui rentang. Kumpulan kesalahan memungkinkan klien memperbaiki seluruh masukan dalam satu putaran, alih-alih mengulang permintaan beberapa kali. Pemeriksaan typeof data.skor !== 'number' tidak cukup bila NaN atau Infinity harus ditolak, sebab operasi aritmetika di hilir dapat menghasilkan kedua nilai tersebut meskipun JSON.parse menolaknya pada masukan JSON standar. Tambahkan Number.isFinite(data.skor) agar nilai non-finite tertangkap sebelum masuk ke lapisan bisnis.

Serialisasi respons JSON#

Serialisasi respons dilakukan dengan JSON.stringify(payload) untuk menghasilkan string JSON, lalu menulis string tersebut sebagai body respons dengan content-type: application/json; charset=utf-8. Penambahan charset=utf-8 penting karena JSON standar mengizinkan UTF-8, UTF-16, dan UTF-32, sehingga eksplisit charset menghilangkan ambiguitas. Header content-length dihitung dengan Buffer.byteLength(body), bukan body.length, karena body.length menghitung kode unit UTF-16 dan bukan byte yang sebenarnya dikirim.

js api-serialisasi.js
const http = require('node:http');

function kirimJson(res, status, payload, { indent = 0 } = {}) {
  const body = JSON.stringify(payload, null, indent);
  res.writeHead(status, {
    'content-type': 'application/json; charset=utf-8',
    'content-length': Buffer.byteLength(body),
  });
  res.end(body);
}

const server = http.createServer((req, res) => {
  if (req.url !== '/data') {
    kirimJson(res, 404, { error: 'tidak ditemukan' });
    return;
  }
  kirimJson(res, 200, {
    judul: 'Catatan berbahasa Indonesia',
    tag: ['belajar', 'node.js', 'http'],
    angka: 1024,
    aktif: true,
  }, { indent: 2 });
});

server.listen(0, '127.0.0.1', async () => {
  const { port } = server.address();
  const r = await fetch(`http://127.0.0.1:${port}/data`);
  const body = await r.text();
  console.log('status:', r.status);
  console.log('content-type:', r.headers.get('content-type'));
  console.log('content-length:', r.headers.get('content-length'));
  console.log('byte aktual:', Buffer.byteLength(body));
  console.log('body:');
  console.log(body);
  server.close();
});
text Keluaran
status: 200
content-type: application/json; charset=utf-8
content-length: 135
byte aktual: 135
body:
{
  "judul": "Catatan berbahasa Indonesia",
  "tag": [
    "belajar",
    "node.js",
    "http"
  ],
  "angka": 1024,
  "aktif": true
}

Indentasi dengan argumen ketiga JSON.stringify(payload, null, 2) menghasilkan JSON yang mudah dibaca manusia, berguna pada endpoint publik yang sering dipakai untuk debugging. Pada produksi, indentasi sebaiknya dimatikan agar ukuran respons lebih kecil dan kompresi gzip lebih efektif. Karakter non-ASCII seperti huruf Indonesia é atau emoji tetap aman karena JSON.stringify tidak meng-escape karakter UTF-8 yang valid, sehingga Buffer.byteLength menghitung byte yang tepat.

Kontrak error yang konsisten#

Kontrak error yang konsisten memudahkan klien mengurai pesan kesalahan. Bentuk yang lazim dipakai adalah objek { error: string, detail?: ... } dengan error berupa string pendek yang stabil dan detail berupa informasi tambahan yang dapat berubah antar versi. Pemusatan serialisasi pada fungsi json atau kirimJson memastikan seluruh titik keluar, baik sukses maupun gagal, mengeluarkan header yang sama. Pemakaian kode status yang sesuai semantiknya, seperti 400 untuk sintaks JSON salah, 422 untuk validasi gagal, dan 404 untuk rute tidak ditemukan, memberi sinyal yang dapat diprogram oleh klien.

Kesalahan umum#

Lupa memeriksa content-type permintaan sebelum mengurai JSON menyebabkan JSON.parse dipanggil pada body yang bukan JSON, misalnya form urlencoded atau multipart. Pemeriksaan req.headers['content-type'] harus dimulai dengan application/json sebelum parsing dilakukan. Penolakan dengan 415 Unsupported Media Type menjadi respons yang tepat untuk permintaan dengan tipe konten yang tidak didukung.

Pemakaian body.length untuk content-length menghasilkan nilai salah ketika body memuat karakter non-ASCII. String "Catatan berbahasa Indonesia" panjangnya 28 kode unit UTF-16 dan jumlahnya sama dengan byte UTF-8 sebab seluruh karakternya berada dalam rentang ASCII. Namun string "Ménüganjil" panjangnya 10 kode unit UTF-16 tetapi memerlukan 12 byte UTF-8 sebab é dan ü masing-masing menempati dua byte. Penggantian dengan Buffer.byteLength(body) menjaga content-length selalu sama dengan byte yang dikirim.

Mengembalikan objek error mentah seperti { error: err } menghasilkan { "error": {} } karena JSON.stringify tidak menyerialisasikan properti non-enumerable objek Error. Konversi eksplisit ke { error: err.message } atau { error: String(err) } menghasilkan respons yang dapat dibaca klien.

Ringkasan#

  • Penguraian body JSON dilakukan dengan membaca stream req dan memanggil JSON.parse di dalam try/catch, dengan kesalahan sintaks diterjemahkan ke respons 400 Bad Request.
  • Validasi manual memeriksa tipe, keberadaan, dan rentang setiap field, dan kesalahan dikumpulkan ke deret agar satu respons 422 Unprocessable Entity memuat seluruh masalah.
  • Serialisasi respons memakai JSON.stringify dengan header content-type: application/json; charset=utf-8 dan content-length dihitung lewat Buffer.byteLength, bukan string.length.
  • Kontrak error { error, detail } yang dipusatkan pada satu fungsi pembantu menjaga konsistensi respons pada seluruh titik keluar.

Referensi#

  • HTTP — Node.js — Referensi resmi modul http dengan properti IncomingMessage.headers dan metode ServerResponse.writeHead.
  • JSON — MDN Web Docs — Dokumentasi JSON.parse dan JSON.stringify beserta perilakunya terhadap error dan indentasi.