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:
- Saat pertama dibutuhkan, baca
data/tugas.jsonke memori (cache). Kalau file belum ada (ENOENT), mulai dengan array kosong. Kalau file rusak, jangan diam-diam dianggap kosong; lempar error supaya ketahuan. - Setiap perubahan, perbarui cache lalu tulis seluruh array ke file.
- Tulis ke file sementara
tugas.json.tmpdulu, lalurenameketugas.json.renamedi 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. - 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
- Membiarkan client menentukan
id,dibuatPada, atau field sensitif. Kalau body langsung di-spread ({ ...req.body }), client bisa mengirim"id": "palsu". Kita aman karena memakaireq.valid.body(Zod membuang field asing) dan id dibuat server. - Menulis langsung ke file tujuan. Kalau server mati saat menulis, file tinggal setengah dan rusak. Tulis ke
.tmplalurename. - Menganggap file rusak sebagai "kosong".
catch { return [] }lalu penulisan berikutnya akan menimpa seluruh data lama dengan array baru. HanyaENOENTyang boleh dianggap kosong. - Mengurutkan cache dengan
sort().sortmengubah array asli, jadi urutan data tersimpan ikut berubah setiap ada GET. PakaitoSorted()atau salin dulu. - Mengembalikan body pada 204. Status 204 berarti tanpa isi; pakai
res.status(204).end(), bukan.json(...).
Latihan
- Mudah, Tambahkan field opsional
catatan(string maksimal 500 karakter) ke skema create dan update, lalu uji dengancurl. - Sedang, Tambahkan endpoint
GET /api/v1/tugas/statistikyang mengembalikan{ total, selesai, belum, perPrioritas: { rendah, sedang, tinggi } }. Perhatikan urutan rute. - Menantang, Tambahkan endpoint
PUT /api/v1/tugas/:idyang 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
- CRUD dipetakan ke REST: POST (201), GET (200), PATCH (200), DELETE (204), dengan 404/422 untuk kegagalan.
- Pisahkan lapisan penyimpanan, validasi, rute, dan aplikasi agar mudah diuji dan mudah pindah ke database.
- Simpan ke file JSON dengan pola tulis-ke-
.tmp-lalu-renamedan antrekan penulisan. - Hanya
ENOENTyang boleh dianggap "data kosong"; file rusak harus memunculkan error. - Pisahkan
appdarilistensupaya bisa diuji dengannode:testdanfetchdi port acak. - File JSON hanyalah jembatan; untuk banyak pengguna dan banyak proses, gunakan database.