Implementasi menyatukan Express, Prisma, dan zod ke dalam satu tata letak proyek yang mengikuti desain API pada topik sebelumnya. Topik ini memperlihatkan lapisan arsitektur dari permintaan masuk sampai basis data, schema Prisma untuk pengguna dan catatan, serta kode rute catatan dengan validasi zod yang dipasang sebagai middleware. Kode dipisahkan menjadi app.js sebagai titik masuk dan routes-catatan.js sebagai router, dengan mock basis data in-memory menggantikan Prisma agar kode dapat dijalankan di sandbox.
Lapisan arsitektur Express#
Permintaan masuk melewati beberapa lapisan sebelum mencapai basis data dan kembali sebagai respons JSON. Lapisan pertama adalah middleware umum yang mencatat permintaan dan mengurai body JSON. Lapisan kedua adalah middleware auth yang memverifikasi JWT dan menempatkan penggunaId pada req. Lapisan ketiga adalah router catatan yang mencocokkan method dan jalur ke controller. Controller menjalankan validasi zod pada req.body, memanggil service untuk logika bisnis, dan mengirim respons sesuai kontrak. Service memanggil Prisma Client yang menerbitkan SQL ke PostgreSQL.
Pemisahan lapisan ini menjaga setiap berkas tetap pendek dan dapat diganti. Middleware auth dapat diganti dari JWT ke session tanpa menyentuh router. Controller dapat diuji dengan mock service, sehingga pengujian tidak memerlukan basis data. Service dapat diuji dengan mock Prisma Client, sehingga pengujian unit berjalan cepat. Pemisahan juga menjaga satu berkas memiliki satu tanggung jawab, yang memudahkan navigasi saat proyek tumbuh.
Skema basis data Prisma#
Skema basis data dideklarasikan pada prisma/schema.prisma sebagai sumber kebenaran. Berkas ini menyatakan dua model, yaitu Pengguna dan Catatan, dengan relasi satu-ke-banyak. Atribut @id dan @default(autoincrement()) membuat kolom kunci utama dengan nilai otomatis. Atribut @unique pada email memastikan tidak ada dua pengguna dengan email yang sama. Atribut @updatedAt pada diperbaruiPada mengisi kolom dengan waktu saat ini setiap kali baris diperbarui, sehingga aplikasi tidak perlu menetapkan nilai secara manual.
// tidak-dijalankan: butuh prisma CLI dan server PostgreSQL
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("APP_DB_URL")
}
model Pengguna {
id Int @id @default(autoincrement())
email String @unique
kataSandi String
nama String
dibuatPada DateTime @default(now())
catatan Catatan[]
}
model Catatan {
id Int @id @default(autoincrement())
judul String @db.VarChar(120)
isi String
tag String[] @default([])
penggunaId Int
pengguna Pengguna @relation(fields: [penggunaId], references: [id], onDelete: Cascade)
dibuatPada DateTime @default(now())
diperbaruiPada DateTime @updatedAt
@@index([penggunaId])
}Relasi pada skema di atas dideklarasikan menggunakan @relation(fields: [penggunaId], references: [id], onDelete: Cascade). Deklarasi ini menunjukkan bahwa penggunaId pada model Catatan merujuk pada id milik Pengguna. Ketika seorang pengguna dihapus, seluruh catatan miliknya juga akan ikut terhapus. Atribut onDelete: Cascade dipilih karena catatan tanpa pemilik tidak memiliki makna dalam layanan ini, sehingga tidak perlu dipertahankan sebagai data tanpa referensi yang valid.
Indeks @@index([penggunaId]) digunakan untuk mempercepat kueri yang mengambil daftar catatan berdasarkan pengguna. Kueri semacam ini merupakan salah satu kueri yang paling sering digunakan oleh endpoint GET /catatan.
Perintah prisma migrate dev membaca skema ini, membandingkannya dengan keadaan basis data, dan menghasilkan berkas migrasi SQL yang dikomit ke kontrol versi. Perintah prisma generate menghasilkan Prisma Client yang mengetik seluruh operasi pada model, sehingga kesalahan tipe pada kolom tertangkap pada saat kompilasi TypeScript. Pada sandbox ini, prisma CLI dan server PostgreSQL tidak tersedia, sehingga skema ditandai // tidak-dijalankan dan diverifikasi dengan membaca struktur secara manual.
Rute catatan dengan validasi#
Rute catatan dipisahkan ke berkas routes-catatan.js yang mengekspor fungsi pembuat router. Pemisahan ini mengikuti pola pada topik router-dan-struktur, yaitu router sebagai modul yang dapat dipasang pada prefiks di app.js. Schema SkemaCatatan dideklarasikan di dalam berkas yang sama dan dipakai pada POST dan PUT melalui safeParse, sehingga masukan yang tidak valid ditolak sebelum mencapai logika simpan atau perbarui.
// tidak-dijalankan: butuh Express dan zod yang dipasang di /tmp/implementasi
const express = require('express');
const { z } = require('zod');
const SkemaCatatan = z.object({
judul: z
.string()
.min(3, 'Judul minimal 3 karakter')
.max(120, 'Judul maksimal 120 karakter'),
isi: z.string().min(1, 'Isi tidak boleh kosong'),
tag: z.array(z.string()).max(5, 'Maksimal 5 tag').optional().default([]),
});
function buatRouterCatatan(db, idBerikutnya) {
const router = express.Router();
router.get('/', (req, res) => {
const milikPengguna = [...db.values()].filter(
(c) => c.penggunaId === req.penggunaId
);
res.json({
data: milikPengguna,
meta: { total: milikPengguna.length, halaman: 1, perHalaman: 20 },
});
});
router.post('/', (req, res) => {
const hasil = SkemaCatatan.safeParse(req.body);
if (!hasil.success) {
return res.status(400).json({
error: {
kode: 'MASUKAN_TIDAK_VALID',
pesan: 'Body permintaan tidak lolos validasi',
detail: hasil.error.issues.map((i) => ({
bidang: i.path.join('.'),
pesan: i.message,
})),
},
});
}
const id = idBerikutnya();
const sekarang = new Date().toISOString();
const catatan = {
id,
...hasil.data,
penggunaId: req.penggunaId,
dibuatPada: sekarang,
diperbaruiPada: sekarang,
};
db.set(id, catatan);
res.status(201).json({ data: catatan });
});
router.get('/:id', (req, res) => {
const id = Number(req.params.id);
const catatan = db.get(id);
if (!catatan || catatan.penggunaId !== req.penggunaId) {
return res
.status(404)
.json({ error: { kode: 'TIDAK_DITEMUKAN', pesan: 'Catatan tidak ditemukan' } });
}
res.json({ data: catatan });
});
router.put('/:id', (req, res) => {
const id = Number(req.params.id);
const catatan = db.get(id);
if (!catatan || catatan.penggunaId !== req.penggunaId) {
return res
.status(404)
.json({ error: { kode: 'TIDAK_DITEMUKAN', pesan: 'Catatan tidak ditemukan' } });
}
const hasil = SkemaCatatan.safeParse(req.body);
if (!hasil.success) {
return res.status(400).json({
error: {
kode: 'MASUKAN_TIDAK_VALID',
pesan: 'Body permintaan tidak lolos validasi',
detail: hasil.error.issues.map((i) => ({
bidang: i.path.join('.'),
pesan: i.message,
})),
},
});
}
const diperbaruiPada = new Date().toISOString();
const baru = {
...catatan,
...hasil.data,
penggunaId: catatan.penggunaId,
dibuatPada: catatan.dibuatPada,
diperbaruiPada,
};
db.set(id, baru);
res.json({ data: baru });
});
router.delete('/:id', (req, res) => {
const id = Number(req.params.id);
const catatan = db.get(id);
if (!catatan || catatan.penggunaId !== req.penggunaId) {
return res
.status(404)
.json({ error: { kode: 'TIDAK_DITEMUKAN', pesan: 'Catatan tidak ditemukan' } });
}
db.delete(id);
res.status(204).end();
});
return router;
}
module.exports = buatRouterCatatan;Fungsi buatRouterCatatan menerima db dan idBerikutnya sebagai parameter, sehingga router dapat diuji dengan mock basis data yang berbeda. Pola injeksi ini menggantikan impor langsung Prisma Client pada berkas router, yang memudahkan pengujian tanpa basis data sungguhan. Pada implementasi produksi, db diganti dengan instance Prisma Client dan idBerikutnya dihilangkan sebab Prisma mengisi id otomatis melalui @default(autoincrement()). Pada demo sandbox, db berupa Map JavaScript dan idBerikutnya berupa penghitung sederhana.
Otorisasi dilakukan dengan pemeriksaan catatan.penggunaId !== req.penggunaId pada setiap rute GET /:id, PUT /:id, dan DELETE /:id. Pemeriksaan ini mengembalikan 404 alih-alih 403 saat catatan tidak dimiliki pengguna terotentikasi, sebab menyatakan 404 menyembunyikan keberadaan catatan milik pengguna lain dari klien. Konvensi ini disebut existence-based authorization dan lazim dipakai pada layanan yang sumber dayanya bersifat privat per pengguna.
Titik masuk aplikasi#
Berkas app.js menjadi titik masuk yang memuat router dan memasang middleware. Middleware umum dipasang lebih dulu, yaitu express.json() untuk mengurai body dan middleware pencatat untuk menampilkan setiap permintaan pada log. Middleware auth dipasang hanya pada prefiks /catatan dengan app.use('/catatan', middlewareAuth), sehingga rute /auth/register dan /auth/login tidak memerlukan token. Router catatan dipasang pada prefiks yang sama dengan app.use('/catatan', catatanRouter(db, idBerikutnya)). Middleware 404 dan middleware penanganan error terpusat dipasang paling akhir.
// tidak-dijalankan: butuh Express dan zod yang dipasang di /tmp/implementasi
const express = require('express');
const catatanRouter = require('./routes-catatan');
// Mock basis data in-memory, pengganti Prisma pada demo terisolasi.
const db = new Map();
db.set(42, {
id: 42,
judul: 'Belajar Node.js minggu pertama',
isi: 'Catatan tentang modul inti dan stream.',
tag: ['node', 'stream'],
penggunaId: 7,
dibuatPada: '2026-11-02T08:30:00.000Z',
diperbaruiPada: '2026-11-02T08:30:00.000Z',
});
let urutId = 43;
const idBerikutnya = () => urutId++;
const app = express();
app.use(express.json());
// Middleware pencatat permintaan.
app.use((req, res, next) => {
console.log(`${req.method} ${req.url}`);
next();
});
// Middleware auth yang memverifikasi token demo.
app.use('/catatan', (req, res, next) => {
const auth = req.headers.authorization;
if (!auth || !auth.startsWith('Bearer ')) {
return res
.status(401)
.json({ error: { kode: 'TIDAK_TEROTENTIKASI', pesan: 'Token tidak disertakan' } });
}
const token = auth.slice(7);
if (token !== 'demo-token-7') {
return res
.status(401)
.json({ error: { kode: 'TIDAK_TEROTENTIKASI', pesan: 'Token tidak sah' } });
}
req.penggunaId = 7;
next();
});
app.use('/catatan', catatanRouter(db, idBerikutnya));
// Middleware 404.
app.use((req, res) => {
res
.status(404)
.json({ error: { kode: 'TIDAK_DITEMUKAN', pesan: 'Endpoint tidak ditemukan' } });
});
// Middleware penanganan error terpusat.
app.use((err, req, res, next) => {
res
.status(500)
.json({ error: { kode: 'KESALAHAN_INTERNAL', pesan: 'Terjadi kesalahan tak terduga' } });
});
const server = app.listen(0, async () => {
const port = server.address().port;
const base = `http://127.0.0.1:${port}`;
const headers = { Authorization: 'Bearer demo-token-7', 'Content-Type': 'application/json' };
const a = await fetch(`${base}/catatan`);
console.log('GET /catatan tanpa token ->', a.status, await a.text());
const b = await fetch(`${base}/catatan`, { headers: { Authorization: 'Bearer demo-token-7' } });
console.log('GET /catatan dengan token ->', b.status, await b.text());
const c = await fetch(`${base}/catatan`, {
method: 'POST',
headers,
body: JSON.stringify({ judul: 'Catatan baru', isi: 'Isi catatan baru.' }),
});
console.log('POST /catatan valid ->', c.status, await c.text());
const d = await fetch(`${base}/catatan`, {
method: 'POST',
headers,
body: JSON.stringify({ judul: 'ab', isi: '' }),
});
console.log('POST /catatan invalid ->', d.status, await d.text());
const e = await fetch(`${base}/catatan/42`, { headers: { Authorization: 'Bearer demo-token-7' } });
console.log('GET /catatan/42 ->', e.status, await e.text());
const f = await fetch(`${base}/catatan/999`, { headers: { Authorization: 'Bearer demo-token-7' } });
console.log('GET /catatan/999 ->', f.status, await f.text());
server.close();
});# pasang Express dan zod sekali untuk demo topik ini: cd /tmp/implementasi && npm install express zod@^3.25
# jalankan app.js dari direktori topik, NODE_PATH mengarah ke node_modules demo
# sed menyelaraskan timestamp yang berubah-ubah tiap jalankan
NODE_PATH=/tmp/implementasi/node_modules node app.js 2>&1 \
| sed -E 's/2026-[0-9T:.-]+Z/TIMESTAMP/g'GET /catatan
GET /catatan tanpa token -> 401 {"error":{"kode":"TIDAK_TEROTENTIKASI","pesan":"Token tidak disertakan"}}
GET /catatan
GET /catatan dengan token -> 200 {"data":[{"id":42,"judul":"Belajar Node.js minggu pertama","isi":"Catatan tentang modul inti dan stream.","tag":["node","stream"],"penggunaId":7,"dibuatPada":"TIMESTAMP","diperbaruiPada":"TIMESTAMP"}],"meta":{"total":1,"halaman":1,"perHalaman":20}}
POST /catatan
POST /catatan valid -> 201 {"data":{"id":43,"judul":"Catatan baru","isi":"Isi catatan baru.","tag":[],"penggunaId":7,"dibuatPada":"TIMESTAMP","diperbaruiPada":"TIMESTAMP"}}
POST /catatan
POST /catatan invalid -> 400 {"error":{"kode":"MASUKAN_TIDAK_VALID","pesan":"Body permintaan tidak lolos validasi","detail":[{"bidang":"judul","pesan":"Judul minimal 3 karakter"},{"bidang":"isi","pesan":"Isi tidak boleh kosong"}]}}
GET /catatan/42
GET /catatan/42 -> 200 {"data":{"id":42,"judul":"Belajar Node.js minggu pertama","isi":"Catatan tentang modul inti dan stream.","tag":["node","stream"],"penggunaId":7,"dibuatPada":"TIMESTAMP","diperbaruiPada":"TIMESTAMP"}}
GET /catatan/999
GET /catatan/999 -> 404 {"error":{"kode":"TIDAK_DITEMUKAN","pesan":"Catatan tidak ditemukan"}}Keluaran memperlihatkan enam skenario yang mencakup seluruh cabang utama. Permintaan tanpa token mendapat 401 dengan kode TIDAK_TEROTENTIKASI, sedangkan permintaan dengan token mendapat 200 dengan daftar catatan milik pengguna 7. Permintaan POST valid mendapat 201 dengan catatan baru yang id dihasilkan server dan tag diisi larik kosong secara default. Permintaan POST invalid mendapat 400 dengan detail yang dipetakan dari error.issues zod. Permintaan GET /catatan/42 mengembalikan catatan yang dimiliki pengguna, sedangkan GET /catatan/999 mengembalikan 404 sebab catatan tidak ada.
Pola middleware auth yang dipasang pada prefiks /catatan sebelum router adalah pola yang mengikat otorisasi dengan rute, bukan dengan handler. Setiap rute baru yang ditambahkan pada routes-catatan.js otomatis terlindungi tanpa perubahan pada handler. Pola ini juga menjaga handler tetap ringkas sebab req.penggunaId sudah terisi ketika handler dipanggil, sehingga handler tidak perlu memverifikasi token.
Kesalahan umum#
Memeriksa req.params.id sebagai string tanpa konversi. Number(req.params.id) wajib dipanggil sebelum pembandingan numerik atau pencarian pada Map yang berisi kunci numerik. Tanpa konversi, db.get("42") mengembalikan undefined sebab Map membandingkan kunci dengan kesetaraan ketat, sehingga string "42" tidak sama dengan angka 42.
Memasang middleware auth pada level aplikasi alih-alih prefiks. Pemasangan app.use(middlewareAuth) menyebabkan seluruh rute, termasuk /auth/register dan /auth/login, memerlukan token, sehingga pengguna baru tidak dapat mendaftar. Pasang middleware auth pada prefiks yang memang memerlukan otorisasi, yaitu app.use('/catatan', middlewareAuth), agar rute publik tetap bebas diakses.
Mengembalikan 403 alih-alih 404 saat catatan tidak dimiliki pengguna. 403 menyatakan bahwa sumber daya ada tetapi tidak boleh diakses, yang membocorkan keberadaan catatan milik pengguna lain. 404 menyatakan bahwa sumber daya tidak ada dari sudut pandang pengguna, yang lebih tepat untuk layanan dengan sumber daya privat per pengguna.
Ringkasan#
- Permintaan masuk melewati middleware umum, middleware auth, router, controller, dan service sebelum mencapai Prisma Client dan PostgreSQL, dengan setiap lapisan memiliki satu tanggung jawab.
- Skema
prisma/schema.prismamenyatakan dua model dengan relasi satu-ke-banyak, kunci asingpenggunaId, indeks pada kunci asing, danonDelete: Cascadeagar catatan ikut terhapus saat pengguna dihapus. - Router catatan pada
routes-catatan.jsmenerimadbdanidBerikutnyasebagai parameter injeksi, sehingga dapat diuji dengan mock basis data tanpa PostgreSQL. - Middleware auth dipasang pada prefiks
/catatanagar rute publik tetap bebas diakses, dan otorisasi per catatan mengembalikan404alih-alih403untuk menyembunyikan keberadaan catatan milik pengguna lain.
Referensi#
- Express — Using middleware — Express.js
— Panduan resmi pemasangan middleware pada level aplikasi dan level rute, mencakup urutan dan
app.use(prefix, fn). - Prisma — Schema overview — Prisma
— Dokumentasi resmi skema Prisma, mencakup
model,@relation,@id, dan@@index. - Zod — Objects — zod
— Referensi API objek zod beserta
safeParse,min,max, danoptionalyang dipakai pada schema catatan. - Express — Error handling — Express.js
— Panduan resmi middleware penanganan error dengan empat argumen
(err, req, res, next).