Agentic AI with Java: Live Cohort
JavaScriptProjects

Project 3: JSON Task API

Build and exercise a complete in-memory CRUD API with Node.js.

This project combines modules, async iteration, HTTP, validation, status codes, and error handling. The API can list, create, read, update, and delete tasks. It uses memory so you can focus on the request flow; data resets when the server restarts. Create a folder named task-api, then save this as server.mjs.

server.mjs
import { createServer } from "node:http";

const tasks = new Map();
let nextId = 1;

class HttpError extends Error {
  constructor(status, message) {
    super(message);
    this.status = status;
  }
}

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

async function readJson(request) {
  if (request.headers["content-type"]?.split(";")[0].trim().toLowerCase() !== "application/json") {
    throw new HttpError(415, "Content-Type must be application/json");
  }
  const chunks = [];
  let size = 0;
  for await (const chunk of request) {
    size += chunk.length;
    if (size > 16_384) throw new HttpError(413, "Request body is too large");
    chunks.push(chunk);
  }
  try {
    return JSON.parse(Buffer.concat(chunks).toString("utf8"));
  } catch {
    throw new HttpError(400, "Invalid JSON body");
  }
}

function validTitle(value) {
  return typeof value === "string" && value.trim().length > 0 && value.trim().length <= 100;
}

const server = createServer(async (request, response) => {
  try {
    const url = new URL(request.url, "http://localhost");
    const method = request.method;

    if (url.pathname === "/health" && method === "GET") {
      send(response, 200, { status: "ok" });
      return;
    }

    if (url.pathname === "/tasks") {
      if (method === "GET") {
        send(response, 200, [...tasks.values()]);
        return;
      }
      if (method === "POST") {
        const input = await readJson(request);
        if (!input || typeof input !== "object" || Array.isArray(input) || !validTitle(input.title)) {
          throw new HttpError(400, "title must be 1-100 characters");
        }
        const task = { id: nextId++, title: input.title.trim(), done: false };
        tasks.set(task.id, task);
        response.setHeader("Location", `/tasks/${task.id}`);
        send(response, 201, task);
        return;
      }
      throw new HttpError(405, "Method not allowed");
    }

    const match = /^\/tasks\/(\d+)$/.exec(url.pathname);
    if (match) {
      const id = Number(match[1]);
      const task = tasks.get(id);
      if (!task) throw new HttpError(404, "Task not found");

      if (method === "GET") {
        send(response, 200, task);
        return;
      }
      if (method === "PATCH") {
        const input = await readJson(request);
        if (!input || typeof input !== "object" || Array.isArray(input)) {
          throw new HttpError(400, "Expected an object");
        }
        if ("title" in input && !validTitle(input.title)) {
          throw new HttpError(400, "title must be 1-100 characters");
        }
        if ("done" in input && typeof input.done !== "boolean") {
          throw new HttpError(400, "done must be a boolean");
        }
        if ("title" in input) task.title = input.title.trim();
        if ("done" in input) task.done = input.done;
        send(response, 200, task);
        return;
      }
      if (method === "DELETE") {
        tasks.delete(id);
        send(response, 204);
        return;
      }
      throw new HttpError(405, "Method not allowed");
    }

    throw new HttpError(404, "Route not found");
  } catch (error) {
    if (response.headersSent) return;
    const status = error instanceof HttpError ? error.status : 500;
    if (status === 500) console.error(error);
    send(response, status, { error: status === 500 ? "Internal server error" : error.message });
  }
});

const port = Number(process.env.PORT) || 3000;
server.listen(port, () => console.log(`Listening on http://localhost:${port}`));
process.once("SIGTERM", () => server.close());
process.once("SIGINT", () => server.close());

Run node server.mjs in one terminal. In a second terminal, save and run this client:

client.mjs
const base = "http://localhost:3000";
const created = await fetch(`${base}/tasks`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ title: "Learn JavaScript" }),
});
console.log(created.status, await created.json()); // 201, new task

const changed = await fetch(`${base}/tasks/1`, {
  method: "PATCH",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ done: true }),
});
console.log(changed.status, await changed.json()); // 200, done: true

const listed = await fetch(`${base}/tasks`);
console.log(await listed.json()); // array with one completed task

const removed = await fetch(`${base}/tasks/1`, { method: "DELETE" });
console.log(removed.status); // 204; do not call removed.json()

readJson() reads the request body as chunks and limits it to 16 KiB. JSON.parse() checks syntax; the route checks shape and field types. Map stores tasks by numeric ID. The response helper sets JSON headers and serializes data, while an HTTP 204 has no body. The HttpError class carries expected client errors; unexpected errors are logged on the server and returned as a generic 500 so internal details are not leaked.

Test the edges

Send an empty title and expect 400. Request /tasks/999 and expect 404. Send a POST without a JSON content type and expect 415. Restart the server and verify the list is empty. These cases teach why a happy-path demo is not a complete API.

For a production service, add persistent storage, authentication and per-user authorization, rate limits, timeouts, structured logs, and automated integration tests. Those are separate concerns; this project gives you the core request and response flow to build on.

Written By: Shiva Srivastava

How is this guide?

Last updated on

On this page