kodingup.camp

Beranda/Node.js Backend: Dasar, Express & REST API

CRUD dengan Data di Memori lalu File JSON

Modul 9 dari 104 menit bacaCRUD API

Isi modul

    Kenapa ini penting

    CRUD (Create, Read, Update, Delete) adalah inti dari hampir semua aplikasi: to-do list, toko online, sistem kasir, blog. Kalau kamu bisa membuat CRUD yang rapi (validasi, status code benar, error konsisten, data tersimpan), kamu sudah memegang 80% pekerjaan backend sehari-hari. Sisanya adalah variasi: database sungguhan, autentikasi, upload file.

    Modul ini menyatukan semua yang sudah dipelajari menjadi satu proyek: Tugas API. Kita mulai dengan menyimpan data di memori (array), lalu memindahkannya ke file JSON supaya data tidak hilang saat server restart. File JSON adalah jembatan: cukup sederhana untuk dipahami, tapi sudah memaksa kita memikirkan hal-hal yang nanti juga muncul di database, seperti penulisan yang aman dan pemisahan lapisan data dari lapisan HTTP.

    Konsep inti

    Dari memori ke file

    Data di array hidup selama proses Node hidup. Begitu server restart (termasuk setiap kali --watch mendeteksi perubahan), semuanya hilang. Ibarat mencatat pesanan di papan tulis: praktis, tapi terhapus saat warung tutup. File JSON adalah buku catatan: tetap ada besok pagi.

    Strategi kita:

    1. Saat pertama dibutuhkan, baca data/tugas.json ke memori (cache). Kalau file belum ada (ENOENT), mulai dengan array kosong. Kalau file rusak, jangan diam-diam dianggap kosong; lempar error supaya ketahuan.
    2. Setiap perubahan, perbarui cache lalu tulis seluruh array ke file.
    3. Tulis ke file sementara tugas.json.tmp dulu, lalu rename ke tugas.json. rename di sistem file yang sama bersifat atomik: file tujuan selalu versi lama yang utuh atau versi baru yang utuh, tidak pernah setengah tertulis kalau server mati di tengah jalan.
    4. Antrekan penulisan dengan rantai Promise supaya dua request yang hampir bersamaan tidak saling menimpa file sementara.

    Pisahkan lapisan

    Lapisan File Tanggung jawab
    Penyimpanan store.js Baca/tulis data, tidak tahu apa-apa soal HTTP
    Validasi schemas.js, validate.js Bentuk data yang diizinkan
    Rute tugas.routes.js Menerjemahkan HTTP ke operasi data dan sebaliknya
    Aplikasi app.js Merakit middleware, router, 404, error handler
    Entry point server.js Hanya listen, supaya app bisa diuji tanpa membuka port tetap

    Keuntungannya: saat pindah ke database (PostgreSQL, SQLite), kamu cukup mengganti store.js. Rute, validasi, dan error handling tidak berubah.

    Operasi data yang tidak mengubah array asli

    Kita memakai metode array yang mengembalikan array baru: toSorted (mengurutkan tanpa merusak urutan asli cache), with(index, nilai) (mengganti satu elemen), dan filter (menghapus). Selain lebih aman, ini membuat alur "baca → ubah → simpan" jelas.

    Keterbatasan file JSON

    File JSON cocok untuk belajar, prototipe, atau data kecil yang jarang berubah. Ia tidak cocok untuk produksi dengan banyak pengguna: seluruh file ditulis ulang di setiap perubahan, tidak ada query, tidak ada transaksi, dan kalau kamu menjalankan dua proses server, keduanya punya cache sendiri yang saling menimpa. Di bagian 2 kita pindah ke database.

    Contoh kode

    Struktur proyek (file errors.js, schemas.js, validate.js sama persis dengan modul Validasi & Error Handling):

    tugas-api/
    ├── package.json
    ├── data/              # dibuat otomatis, sebaiknya masuk .gitignore
    ├── src/
    │   ├── app.js
    │   ├── server.js
    │   ├── store.js
    │   ├── tugas.routes.js
    │   ├── errors.js
    │   ├── schemas.js
    │   └── validate.js
    └── test/
        └── tugas.test.js
    
    {
      "name": "tugas-api",
      "version": "1.0.0",
      "type": "module",
      "scripts": {
        "dev": "node --watch --env-file-if-exists=.env src/server.js",
        "start": "node --env-file-if-exists=.env src/server.js",
        "test": "node --test"
      },
      "dependencies": {
        "cors": "^2.8.6",
        "express": "^5.3.0",
        "zod": "^4.6.7"
      }
    }
    
    // src/store.js, lapisan penyimpanan file JSON
    import { readFile, writeFile, rename, mkdir } from "node:fs/promises";
    import path from "node:path";
    
    const FILE = process.env.DATA_FILE ?? path.join(import.meta.dirname, "..", "data", "tugas.json");
    
    let cache = null;
    
    export async function bacaSemua() {
      if (cache) return cache;
      try {
        cache = JSON.parse(await readFile(FILE, "utf8"));
      } catch (err) {
        if (err.code !== "ENOENT") throw err; // file rusak = error sungguhan, jangan ditelan
        cache = [];
      }
      return cache;
    }
    
    let antrean = Promise.resolve();
    
    // Tulis ke file sementara dulu, lalu rename: file tidak pernah setengah jadi
    export function simpanSemua(data) {
      cache = data;
      antrean = antrean.then(async () => {
        await mkdir(path.dirname(FILE), { recursive: true });
        const tmp = `${FILE}.tmp`;
        await writeFile(tmp, JSON.stringify(data, null, 2));
        await rename(tmp, FILE);
      });
      return antrean;
    }
    
    // src/tugas.routes.js, lapisan HTTP
    import { Router } from "express";
    import { randomUUID } from "node:crypto";
    import { bacaSemua, simpanSemua } from "./store.js";
    import { validate } from "./validate.js";
    import { NotFoundError } from "./errors.js";
    import { tugasBaruSchema, tugasUpdateSchema, idParamSchema, listQuerySchema } from "./schemas.js";
    
    const router = Router();
    
    // GET /api/v1/tugas?page=1&limit=10&selesai=false&q=node&sort=-dibuatPada
    router.get("/", validate(listQuerySchema, "query"), async (req, res) => {
      const { page, limit, selesai, q, sort } = req.valid.query;
      let hasil = await bacaSemua();
    
      if (selesai) hasil = hasil.filter((t) => t.selesai === (selesai === "true"));
      if (q) hasil = hasil.filter((t) => t.judul.toLowerCase().includes(q.toLowerCase()));
    
      const field = sort.replace("-", "");
      const arah = sort.startsWith("-") ? -1 : 1;
      hasil = hasil.toSorted((a, b) => (a[field] > b[field] ? arah : a[field] < b[field] ? -arah : 0));
    
      const total = hasil.length;
      const data = hasil.slice((page - 1) * limit, page * limit);
      res.json({ data, meta: { page, limit, total, totalPages: Math.ceil(total / limit) } });
    });
    
    router.get("/:id", validate(idParamSchema, "params"), async (req, res) => {
      const semua = await bacaSemua();
      const tugas = semua.find((t) => t.id === req.valid.params.id);
      if (!tugas) throw new NotFoundError("Tugas tidak ditemukan"); // Express 5 meneruskan ke error handler
      res.json({ data: tugas });
    });
    
    router.post("/", validate(tugasBaruSchema), async (req, res) => {
      const semua = await bacaSemua();
      const sekarang = new Date().toISOString();
      // id & tanggal dibuat server; field asing dari client sudah dibuang Zod
      const baru = { id: randomUUID(), ...req.valid.body, dibuatPada: sekarang, diubahPada: sekarang };
      await simpanSemua([...semua, baru]);
      res.status(201).location(`/api/v1/tugas/${baru.id}`).json({ data: baru });
    });
    
    router.patch("/:id", validate(idParamSchema, "params"), validate(tugasUpdateSchema), async (req, res) => {
      const semua = await bacaSemua();
      const index = semua.findIndex((t) => t.id === req.valid.params.id);
      if (index === -1) throw new NotFoundError("Tugas tidak ditemukan");
      const diubah = { ...semua[index], ...req.valid.body, diubahPada: new Date().toISOString() };
      await simpanSemua(semua.with(index, diubah));
      res.json({ data: diubah });
    });
    
    router.delete("/:id", validate(idParamSchema, "params"), async (req, res) => {
      const semua = await bacaSemua();
      const sisa = semua.filter((t) => t.id !== req.valid.params.id);
      if (sisa.length === semua.length) throw new NotFoundError("Tugas tidak ditemukan");
      await simpanSemua(sisa);
      res.status(204).end();
    });
    
    export default router;
    
    // src/app.js, merakit aplikasi (dipakai oleh server.js dan oleh test)
    import express from "express";
    import cors from "cors";
    import tugasRouter from "./tugas.routes.js";
    import { AppError } from "./errors.js";
    
    export const app = express();
    
    app.use(cors({ origin: process.env.CORS_ORIGIN ?? "http://localhost:5173" }));
    app.use(express.json({ limit: "100kb" }));
    
    app.get("/api/v1/health", (req, res) => res.json({ status: "ok" }));
    app.use("/api/v1/tugas", tugasRouter);
    
    app.use((req, res) => {
      res.status(404).json({
        error: { code: "ROUTE_NOT_FOUND", message: `Rute ${req.method} ${req.originalUrl} tidak ada` },
      });
    });
    
    app.use((err, req, res, next) => {
      if (err instanceof AppError) {
        return res.status(err.status).json({
          error: { code: err.code, message: err.message, details: err.details },
        });
      }
      if (err.type === "entity.parse.failed") {
        return res.status(400).json({ error: { code: "INVALID_JSON", message: "Body bukan JSON yang valid" } });
      }
      console.error(err);
      res.status(500).json({ error: { code: "INTERNAL", message: "Terjadi kesalahan di server" } });
    });
    
    // src/server.js, entry point
    import { app } from "./app.js";
    
    const PORT = Number(process.env.PORT ?? 3000);
    app.listen(PORT, (err) => {
      if (err) {
        console.error("Gagal start:", err.message);
        process.exit(1);
      }
      console.log(`Tugas API jalan di http://localhost:${PORT}`);
    });
    

    Jalankan npm install lalu npm run dev, kemudian uji (output asli, id dan waktu akan berbeda di komputermu):

    B=localhost:3000/api/v1
    J='Content-Type: application/json'
    
    # CREATE
    curl -s -w ' [%{http_code}]' -X POST $B/tugas -H "$J" -d '{"judul":"Belajar Express 5","prioritas":"tinggi"}'
    # → {"data":{"id":"57883c74-bb50-4e6f-a197-27138ee8d6d6","judul":"Belajar Express 5","prioritas":"tinggi","selesai":false,"dibuatPada":"2026-10-23T18:11:43.157Z","diubahPada":"2026-10-23T18:11:43.157Z"}} [201]
    curl -s -o /dev/null -X POST $B/tugas -H "$J" -d '{"judul":"Baca dokumentasi Zod"}'
    curl -s -o /dev/null -X POST $B/tugas -H "$J" -d '{"judul":"Push ke GitHub","selesai":true}'
    
    # READ dengan pagination + sort
    curl -s "$B/tugas?limit=2&sort=judul"
    # → {"data":[{"id":"9813e8cf-…","judul":"Baca dokumentasi Zod","prioritas":"sedang","selesai":false,…},
    # →          {"id":"57883c74-…","judul":"Belajar Express 5","prioritas":"tinggi","selesai":false,…}],
    # →  "meta":{"page":1,"limit":2,"total":3,"totalPages":2}}
    
    # READ dengan filter
    curl -s "$B/tugas?selesai=true"
    # → {"data":[{"id":"3cc0eb41-…","judul":"Push ke GitHub","prioritas":"sedang","selesai":true,…}],"meta":{"page":1,"limit":10,"total":1,"totalPages":1}}
    
    # UPDATE sebagian: prioritas "tinggi" tetap, hanya selesai yang berubah
    curl -s -X PATCH $B/tugas/57883c74-bb50-4e6f-a197-27138ee8d6d6 -H "$J" -d '{"selesai":true}'
    # → {"data":{"id":"57883c74-…","judul":"Belajar Express 5","prioritas":"tinggi","selesai":true,"dibuatPada":"2026-10-23T18:11:43.157Z","diubahPada":"2026-10-23T18:11:43.325Z"}}
    
    curl -s -w ' [%{http_code}]' -X PATCH $B/tugas/57883c74-bb50-4e6f-a197-27138ee8d6d6 -H "$J" -d '{}'
    # → {"error":{"code":"VALIDATION_ERROR","message":"Data yang dikirim tidak valid","details":{"formErrors":["Kirim minimal satu field untuk diubah"],"fieldErrors":{}}}} [422]
    
    curl -s -w ' [%{http_code}]' $B/tugas/00000000-0000-4000-8000-000000000000
    # → {"error":{"code":"NOT_FOUND","message":"Tugas tidak ditemukan"}} [404]
    
    # DELETE (204 tanpa body), lalu hapus lagi → 404
    curl -s -i -X DELETE $B/tugas/57883c74-bb50-4e6f-a197-27138ee8d6d6 | head -1
    # → HTTP/1.1 204 No Content
    curl -s -w ' [%{http_code}]' -X DELETE $B/tugas/57883c74-bb50-4e6f-a197-27138ee8d6d6
    # → {"error":{"code":"NOT_FOUND","message":"Tugas tidak ditemukan"}} [404]
    
    # Header Location pada POST
    curl -s -i -X POST $B/tugas -H "$J" -d '{"judul":"Cek header Location"}' | grep -iE "^(HTTP|location)"
    # → HTTP/1.1 201 Created
    # → Location: /api/v1/tugas/94e53d75-5957-4025-82df-e48fc60acea6
    

    Matikan server (Ctrl+C), jalankan lagi, lalu GET /tugas: dua tugas yang tersisa masih ada karena sudah tersimpan di data/tugas.json.

    # Setelah restart:
    # → meta: { page: 1, limit: 10, total: 2, totalPages: 1 }  judul: [ 'Push ke GitHub', 'Baca dokumentasi Zod' ]
    

    Isi file data/tugas.json rapi dan bisa dibaca manusia:

    [
      {
        "id": "9813e8cf-6e4c-4502-852a-eb06785e1573",
        "judul": "Baca dokumentasi Zod",
        "prioritas": "sedang",
        "selesai": false,
        "dibuatPada": "2026-10-23T18:11:43.200Z",
        "diubahPada": "2026-10-23T18:11:43.200Z"
      }
    ]
    

    Terakhir, test otomatis dengan test runner bawaan node:test dan fetch bawaan. Karena app dipisah dari server.js, test bisa menyalakan server di port acak (listen(0)) dan memakai file data sementara.

    // test/tugas.test.js, jalankan: npm test
    import { test, before, after } from "node:test";
    import assert from "node:assert/strict";
    import { tmpdir } from "node:os";
    import path from "node:path";
    
    process.env.DATA_FILE = path.join(tmpdir(), `tugas-test-${Date.now()}.json`); // jangan sentuh data asli
    const { app } = await import("../src/app.js"); // import SETELAH env diset
    
    let server, base;
    before(async () => {
      server = app.listen(0); // port 0 = pilih port kosong acak
      await new Promise((r) => server.once("listening", r));
      base = `http://localhost:${server.address().port}/api/v1`;
    });
    after(() => server.close());
    
    test("POST lalu GET tugas", async () => {
      const res = await fetch(`${base}/tugas`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ judul: "Tes otomatis" }),
      });
      assert.equal(res.status, 201);
      const { data } = await res.json();
      assert.equal(data.prioritas, "sedang");
    
      const get = await fetch(`${base}/tugas/${data.id}`);
      assert.equal(get.status, 200);
    });
    
    test("judul terlalu pendek ditolak 422", async () => {
      const res = await fetch(`${base}/tugas`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ judul: "a" }),
      });
      assert.equal(res.status, 422);
      const body = await res.json();
      assert.deepEqual(body.error.details.fieldErrors.judul, ["Judul minimal 3 karakter"]);
    });
    
    // npm test
    // → ✔ POST lalu GET tugas (45.704833ms)
    // → ✔ judul terlalu pendek ditolak 422 (2.139458ms)
    // → ℹ tests 2
    // → ℹ pass 2
    // → ℹ fail 0
    

    Kesalahan umum

    1. Membiarkan client menentukan id, dibuatPada, atau field sensitif. Kalau body langsung di-spread ({ ...req.body }), client bisa mengirim "id": "palsu". Kita aman karena memakai req.valid.body (Zod membuang field asing) dan id dibuat server.
    2. Menulis langsung ke file tujuan. Kalau server mati saat menulis, file tinggal setengah dan rusak. Tulis ke .tmp lalu rename.
    3. Menganggap file rusak sebagai "kosong". catch { return [] } lalu penulisan berikutnya akan menimpa seluruh data lama dengan array baru. Hanya ENOENT yang boleh dianggap kosong.
    4. Mengurutkan cache dengan sort(). sort mengubah array asli, jadi urutan data tersimpan ikut berubah setiap ada GET. Pakai toSorted() atau salin dulu.
    5. Mengembalikan body pada 204. Status 204 berarti tanpa isi; pakai res.status(204).end(), bukan .json(...).

    Latihan

    1. Mudah, Tambahkan field opsional catatan (string maksimal 500 karakter) ke skema create dan update, lalu uji dengan curl.
    2. Sedang, Tambahkan endpoint GET /api/v1/tugas/statistik yang mengembalikan { total, selesai, belum, perPrioritas: { rendah, sedang, tinggi } }. Perhatikan urutan rute.
    3. Menantang, Tambahkan endpoint PUT /api/v1/tugas/:id yang mengganti seluruh tugas (semua field wajib, tanpa default). Kalau id belum ada, buat baru dengan id itu dan balas 201; kalau sudah ada, ganti dan balas 200. Tulis satu test untuk masing-masing kasus.
    Lihat solusi

    1. Di schemas.js:

    const catatan = z.string().max(500, "Catatan maksimal 500 karakter");
    
    export const tugasBaruSchema = z.object({
      judul,
      prioritas: prioritas.default("sedang"),
      selesai: selesai.default(false),
      catatan: catatan.optional(),
    });
    
    export const tugasUpdateSchema = z
      .object({ judul, prioritas, selesai, catatan })
      .partial()
      .refine((data) => Object.keys(data).length > 0, "Kirim minimal satu field untuk diubah");
    

    2. Daftarkan sebelum router.get("/:id"), karena "statistik" akan gagal validasi UUID kalau ditangkap /:id:

    router.get("/statistik", async (req, res) => {
      const semua = await bacaSemua();
      const perPrioritas = { rendah: 0, sedang: 0, tinggi: 0 };
      for (const t of semua) perPrioritas[t.prioritas]++;
      const selesai = semua.filter((t) => t.selesai).length;
      res.json({ data: { total: semua.length, selesai, belum: semua.length - selesai, perPrioritas } });
    });
    

    3.

    // schemas.js
    export const tugasGantiSchema = z.object({ judul, prioritas, selesai });
    
    // tugas.routes.js
    router.put("/:id", validate(idParamSchema, "params"), validate(tugasGantiSchema), async (req, res) => {
      const semua = await bacaSemua();
      const { id } = req.valid.params;
      const index = semua.findIndex((t) => t.id === id);
      const sekarang = new Date().toISOString();
    
      if (index === -1) {
        const baru = { id, ...req.valid.body, dibuatPada: sekarang, diubahPada: sekarang };
        await simpanSemua([...semua, baru]);
        return res.status(201).location(`/api/v1/tugas/${id}`).json({ data: baru });
      }
      const diganti = { id, ...req.valid.body, dibuatPada: semua[index].dibuatPada, diubahPada: sekarang };
      await simpanSemua(semua.with(index, diganti));
      res.json({ data: diganti });
    });
    
    // test/put.test.js (pakai before/after yang sama seperti tugas.test.js)
    test("PUT membuat lalu mengganti", async () => {
      const id = crypto.randomUUID();
      const kirim = (body) =>
        fetch(`${base}/tugas/${id}`, {
          method: "PUT",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify(body),
        });
      assert.equal((await kirim({ judul: "Versi 1", prioritas: "rendah", selesai: false })).status, 201);
      const res2 = await kirim({ judul: "Versi 2", prioritas: "tinggi", selesai: true });
      assert.equal(res2.status, 200);
      assert.equal((await res2.json()).data.judul, "Versi 2");
    });
    

    Ringkasan

    Video pendamping

    Kalau penjelasan di atas belum masuk, tonton ini. Video dari kanal YouTube pihak lain.

    Build a REST API with Node JS and Express | CRUD API TutorialJavaScript Mastery · Bahasa InggrisBuka di YouTube

    Kuis pemahaman

    Pilih satu jawaban per soal. Penjelasan muncul setelah kamu menjawab.