Framework ছাড়া Node.js-এ REST API বানান (পরীক্ষিত)

Node-এর বিল্ট-ইন module দিয়ে ছোট CRUD API বানিয়ে বুঝুন Express আপনার জন্য কী করে। কোড প্রকাশের আগে চালিয়ে পরীক্ষা করা হয়েছে।

Node.js notes API-তে curl অনুরোধ দেখানো একটি terminal

Express-এর মতো framework আড়াল করে রাখে HTTP server আসলে কী করে। শুধু Node-এর বিল্ট-ইন module দিয়ে একটি ছোট API বানালে অংশগুলো দেখা যায়: routing, request body পড়া, যাচাই, status code ও test। এরপর framework বোঝা সহজ হবে, কারণ সে কী বদলে দিচ্ছে তা জানবেন। নিচের কোড ও আউটপুট প্রকাশের আগে Node.js 22-এ চালানো হয়েছে। Node 18 বা নতুন হলে চলার কথা।

আমরা কী বানাচ্ছি

মেমরিতে data রাখা একটি notes API:

Method ও path কী করে সফল status
GET /notes সব নোট তালিকা 200
POST /notes নোট তৈরি 201
GET /notes/:id একটি নোট পড়া 200
PUT /notes/:id শিরোনাম ও বিষয়বস্তু বদলানো 200
DELETE /notes/:id নোট মোছা 204

কোড

server.mjs নামে সংরক্ষণ করুন। npm install লাগে না:

import { createServer } from "node:http";
import { randomUUID } from "node:crypto";

const notes = new Map();

function send(res, status, body) {
  res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
  res.end(body === undefined ? "" : JSON.stringify(body));
}

async function readJson(req) {
  let raw = "";
  for await (const chunk of req) {
    raw += chunk;
    if (raw.length > 100_000) throw Object.assign(new Error("Body too large"), { status: 413 });
  }
  try {
    return JSON.parse(raw || "{}");
  } catch {
    throw Object.assign(new Error("Invalid JSON"), { status: 400 });
  }
}

function validate(data) {
  if (typeof data.title !== "string" || !data.title.trim()) return "title is required";
  if (data.title.length > 100) return "title must be 100 characters or fewer";
  if (data.body !== undefined && typeof data.body !== "string") return "body must be a string";
  return null;
}

export const server = createServer(async (req, res) => {
  try {
    const { pathname } = new URL(req.url, "http://localhost");
    const parts = pathname.split("/").filter(Boolean);
    if (parts[0] !== "notes" || parts.length > 2) return send(res, 404, { error: "Not found" });
    const id = parts[1];

    if (!id && req.method === "GET") return send(res, 200, [...notes.values()]);

    if (!id && req.method === "POST") {
      const data = await readJson(req);
      const problem = validate(data);
      if (problem) return send(res, 400, { error: problem });
      const note = { id: randomUUID(), title: data.title.trim(), body: data.body ?? "", createdAt: new Date().toISOString() };
      notes.set(note.id, note);
      return send(res, 201, note);
    }

    if (id) {
      const note = notes.get(id);
      if (!note) return send(res, 404, { error: "Note not found" });
      if (req.method === "GET") return send(res, 200, note);
      if (req.method === "PUT") {
        const data = await readJson(req);
        const problem = validate(data);
        if (problem) return send(res, 400, { error: problem });
        Object.assign(note, { title: data.title.trim(), body: data.body ?? "" });
        return send(res, 200, note);
      }
      if (req.method === "DELETE") {
        notes.delete(id);
        return send(res, 204);
      }
    }
    send(res, 405, { error: "Method not allowed" });
  } catch (err) {
    send(res, err.status ?? 500, { error: err.status ? err.message : "Server error" });
  }
});

if (process.argv[1] === new URL(import.meta.url).pathname) {
  const port = process.env.PORT ?? 3000;
  server.listen(port, () => console.log(`Notes API on http://localhost:${port}`));
}

চালিয়ে পরীক্ষা করুন

$ PORT=3917 node server.mjs
Notes API on http://localhost:3917

$ curl -i -X POST localhost:3917/notes -H 'Content-Type: application/json' \
    -d '{"title":"Buy milk","body":"2 litres"}'
HTTP/1.1 201 Created
{"id":"7221c578-...","title":"Buy milk","body":"2 litres","createdAt":"2026-10-27T06:45:58.682Z"}

$ curl -s -X POST localhost:3917/notes -d 'not json'
{"error":"Invalid JSON"}                 # status 400

$ curl -s -X POST localhost:3917/notes -H 'Content-Type: application/json' -d '{"body":"x"}'
{"error":"title is required"}            # status 400

$ curl -s localhost:3917/notes/unknown-id
{"error":"Note not found"}               # status 404

id ও সময় আপনার মেশিনে আলাদা হবে।

এটি কীভাবে কাজ করে

Routing। path-কে অংশে ভাগ করি। /notes-এ এক অংশ, /notes/abc-এ দুই অংশ। অন্য কিছু হলে 404। Framework এটা router দিয়ে করে; এখানে কয়েক লাইন।

Body পড়া। Request body টুকরো টুকরো হয়ে আসে। for await (const chunk of req) সেগুলো জমায়। ১,০০,০০০ অক্ষরে থামি যাতে একজন client সার্ভারের মেমরি ভরে ফেলতে না পারে, আর তখন 413 ফেরত দিই।

যাচাই। ইনপুট কখনো বিশ্বাস করবেন না। validate দেখে শিরোনাম খালি নয় এমন string এবং সর্বোচ্চ ১০০ অক্ষর, আর body থাকলে তা string। ভুল ইনপুটে 400 ও client-কে দেখানোর মতো বার্তা ফেরে।

Status code। নতুন নোটে 201 Created, ফেরত দেওয়ার কিছু না থাকা মোছায় 204 No Content, ভুল ইনপুটে 400, নোট না পেলে 404, অনুমোদিত নয় এমন method-এ 405, বড় body-তে 413।

Error সামলানো। একটি try/catch পুরো handler ঘিরে আছে। ইচ্ছা করে তোলা error-এ status থাকে। অপ্রত্যাশিত কিছু হলে সাধারণ বার্তাসহ 500 যায়, তাই ভেতরের তথ্য client-এ ফাঁস হয় না।

ID। crypto.randomUUID() অনুমান করা কঠিন id বানায়, তাই কেউ ১ থেকে গুনে সব নোট পড়তে পারে না।

process.argv[1] যাচাই। ফাইল সরাসরি চালালেই শুধু server চালু হয়। test এটি import করলে নির্দিষ্ট port-এ কিছু শোনে না।

node:test দিয়ে test করুন

Node-এ বিল্ট-ইন test runner ও বিল্ট-ইন fetch আছে। server.test.mjs নামে সংরক্ষণ করুন:

import test from "node:test";
import assert from "node:assert/strict";
import { server } from "./server.mjs";

let base;
test.before(async () => {
  await new Promise((resolve) => server.listen(0, resolve));
  base = `http://localhost:${server.address().port}`;
});
test.after(() => server.close());

const json = (method, path, body) =>
  fetch(base + path, { method, headers: { "Content-Type": "application/json" }, body: body && JSON.stringify(body) });

test("create, read, update and delete a note", async () => {
  const created = await json("POST", "/notes", { title: "Learn HTTP", body: "status codes" });
  assert.equal(created.status, 201);
  const note = await created.json();

  assert.equal((await json("GET", `/notes/${note.id}`)).status, 200);

  const updated = await json("PUT", `/notes/${note.id}`, { title: "Learn HTTP well" });
  assert.equal((await updated.json()).title, "Learn HTTP well");

  assert.equal((await json("DELETE", `/notes/${note.id}`)).status, 204);
  assert.equal((await json("GET", `/notes/${note.id}`)).status, 404);
});

test("rejects a note without a title", async () => {
  const res = await json("POST", "/notes", { body: "no title" });
  assert.equal(res.status, 400);
  assert.deepEqual(await res.json(), { error: "title is required" });
});

চালান:

$ node --test
✔ create, read, update and delete a note
✔ rejects a note without a title
# tests 2
# pass 2
# fail 0

Port 0 দিলে অপারেটিং সিস্টেম যেকোনো ফাঁকা port দেয়, তাই test অন্য প্রোগ্রামের সঙ্গে ধাক্কা খায় না।

জানা সীমা

  • Restart দিলে data হারায় কারণ তা Map-এ থাকে। পরের ধাপ: নোট SQLite বা Postgres-এ রাখুন।
  • Authentication নেই। যে সার্ভারে পৌঁছাতে পারে সে নোট পড়তে ও মুছতে পারবে। এভাবে ইন্টারনেটে খুলবেন না।
  • Rate limiting ও CORS header নেই। অন্য origin-এর browser অ্যাপ CORS যোগ না করা পর্যন্ত আটকে যাবে।
  • সরল routing। অনেক route হলে framework বা ছোট router library রক্ষণাবেক্ষণে সহজ।
  • আংশিক আপডেটের জন্য PATCH নেই; PUT শিরোনাম ও body পুরো বদলায়।

চ্যালেঞ্জ

১. শিরোনাম দিয়ে ছাঁকতে GET /notes?search=milk যোগ করুন। ২. নোট JSON ফাইলে রাখুন যাতে restart-এ থাকে। ৩. শুধু পাঠানো ক্ষেত্র বদলায় এমন PATCH route যোগ করুন। ৪. Content-Type JSON না হলে 415 ফেরত দিন। ৫. একই API Express দিয়ে লিখে দেখুন framework কতটা কোড বাঁচায়।

মূল কথা: HTTP API মানে route, পার্স করা ইনপুট, যাচাই, সঠিক status code ও test। হাতে একবার বানালে framework আর জাদু থাকে না, বোঝা যায় এমন টুল হয়।


হাতে-কলমে প্রজেক্ট সিরিজ, পর্ব ১ (ছোট পরীক্ষিত প্রজেক্ট): 2 নম্বর (মোট 5)। আগের লেখা: ৬০ লাইনে Python expense tracker CLI বানান (test সহ).

আরও পড়ুন

Python দিয়ে এলোমেলো CSV পরিষ্কার: নাম, তারিখ, ফোন ও ডুপ্লিকেট

আসল data এলোমেলো। standard library দিয়ে contacts পরিষ্কার করা, প্রতিটি বাতিল row ব্যাখ্যা করা ও test-সহ একটি script বানান।

প্রজেক্ট•৫ মিনিটে পড়া