"API yang bagus bukan yang fiturnya paling banyak, tapi yang paling mudah dipakai dengan benar dan paling susah dipakai dengan salah."
Tentang E-Book Ini
E-book ini akan membawa kamu membangun REST API yang benar-benar production-ready — bukan sekadar tutorial CRUD yang berhenti di console.log.
Kita akan bangun sebuah API untuk sistem manajemen produk toko, dari nol sampai siap deploy. Setiap bab menambahkan lapisan yang lebih baik — dari routing sederhana, ke validasi input, ke error handling yang proper, ke struktur folder yang scalable.
Setelah selesai, kamu akan bisa:
- Merancang dan membangun REST API dengan Express
- Menyambungkan API ke PostgreSQL dengan query yang benar
- Memvalidasi input dan menangani error secara konsisten
- Menyusun project dengan struktur yang bisa di-maintain jangka panjang
- Mengimplementasikan pola-pola yang dipakai di production nyata
Prasyarat: Familiar dengan JavaScript dasar (function, async/await, arrow function). Sudah install Node.js (v18+). Sudah baca e-book Git & GitHub atau paham dasar Git.
Daftar Isi
- REST & HTTP: Fondasi yang Harus Kamu Pahami
- Setup Project Node.js yang Benar
- Express: Routing & Request Handling
- Middleware: Lapisan yang Mengontrol Segalanya
- Database: PostgreSQL dengan node-postgres
- Validasi Input dengan Zod
- Error Handling yang Konsisten
- Struktur Folder Production-Ready
- Environment, Config & Security Dasar
- Upload File dengan Multer
Bab 1: REST & HTTP: Fondasi yang Harus Kamu Pahami
HTTP: Bahasa yang Digunakan API
REST API berkomunikasi lewat HTTP. Sebelum nulis satu baris kode pun, kamu perlu paham HTTP dengan baik.
HTTP Request terdiri dari:
METHOD /path HTTP/1.1
Host: api.toko.com
Content-Type: application/json
Authorization: Bearer token123
{ "body": "data" }
HTTP Response terdiri dari:
HTTP/1.1 200 OK
Content-Type: application/json
{ "response": "data" }
HTTP Methods
| Method | Fungsi | Idempotent? |
|---|---|---|
GET | Ambil data | Ya |
POST | Buat data baru | Tidak |
PUT | Update keseluruhan | Ya |
PATCH | Update sebagian | Tidak |
DELETE | Hapus data | Ya |
Idempotent artinya request yang sama yang diulang berkali-kali menghasilkan efek yang sama. GET /products/1 dipanggil 10 kali = hasil sama. POST /products dipanggil 10 kali = 10 produk baru.
HTTP Status Codes yang Paling Penting
2xx — Sukses
200 OK Berhasil
201 Created Resource baru berhasil dibuat
204 No Content Berhasil, tidak ada response body (biasanya DELETE)
3xx — Redirect
301 Moved Permanently URL sudah pindah permanen
304 Not Modified Cache masih valid
4xx — Client Error (kesalahan dari pengirim request)
400 Bad Request Request tidak valid (format salah, validasi gagal)
401 Unauthorized Tidak punya akses (belum login)
403 Forbidden Sudah login tapi tidak punya izin
404 Not Found Resource tidak ditemukan
409 Conflict Konflik (misal: email sudah terdaftar)
422 Unprocessable Entity Input valid secara format tapi tidak bisa diproses
429 Too Many Requests Rate limit exceeded
5xx — Server Error (kesalahan di sisi server)
500 Internal Server Error Error tak terduga di server
503 Service Unavailable Server sedang down/overload
Prinsip REST
REST (Representational State Transfer) bukan protokol atau standar resmi, tapi sekumpulan prinsip desain:
- Client-Server: frontend dan backend terpisah
- Stateless: setiap request mengandung semua informasi yang dibutuhkan. Server tidak menyimpan state antar request.
- Cacheable: response bisa di-cache
- Uniform Interface: antarmuka yang konsisten — ini yang paling penting
Desain URL yang RESTful:
# Resource: products (gunakan noun, bukan verb)
GET /products → ambil semua produk
GET /products/:id → ambil produk tertentu
POST /products → buat produk baru
PUT /products/:id → update produk tertentu (keseluruhan)
PATCH /products/:id → update sebagian produk
DELETE /products/:id → hapus produk
# Nested resource
GET /products/:id/reviews → ambil review produk
POST /products/:id/reviews → tambahkan review ke produk
# Bukan REST yang baik:
GET /getProducts ← jangan pakai verb
POST /createNewProduct ← jangan pakai verb
GET /deleteProduct?id=1 ← gunakan DELETE method
Bab 2: Setup Project Node.js yang Benar
Inisialisasi Project
mkdir toko-api
cd toko-api
git init
npm init -yInstall Dependencies
# Dependencies utama
npm install express
npm install pg # node-postgres untuk PostgreSQL
npm install zod # validasi schema
npm install dotenv # environment variables
npm install helmet # security headers
npm install cors # Cross-Origin Resource Sharing
npm install morgan # HTTP request logger
# Dev dependencies
npm install -D nodemon # auto-restart saat file berubah
npm install -D prettier # code formatterSetup package.json Scripts
{
"name": "toko-api",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "nodemon src/app.js",
"start": "node src/app.js",
"lint": "eslint src/"
}
}Catatan: Kita pakai
"type": "module"untuk menggunakan ES Modules (import/export) alih-alih CommonJS (require). Ini sudah menjadi standar modern untuk project Node.js baru.
Setup .env dan .gitignore
# .env
PORT=3000
NODE_ENV=development
DATABASE_URL=postgresql://postgres:password@localhost:5432/toko_db# .gitignore
node_modules/
.env
.env.local
.env.production
*.log
dist/File Entry Point: src/app.js
// src/app.js
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import morgan from 'morgan';
import 'dotenv/config';
import { productRouter } from './routes/product.routes.js';
import { errorHandler } from './middleware/error.middleware.js';
import { notFoundHandler } from './middleware/notFound.middleware.js';
const app = express();
const PORT = process.env.PORT || 3000;
// Middleware global
app.use(helmet());
app.use(cors());
app.use(morgan('dev'));
app.use(express.json());
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Routes
app.use('/api/v1/products', productRouter);
// Error handlers (harus di paling akhir)
app.use(notFoundHandler);
app.use(errorHandler);
app.listen(PORT, () => {
console.log(`Server berjalan di http://localhost:${PORT}`);
});
export default app;Bab 3: Express: Routing & Request Handling
Konsep Dasar Express
Express adalah minimal web framework untuk Node.js. Intinya sederhana:
app.METHOD(PATH, HANDLER);
// METHOD: get, post, put, patch, delete
// PATH: URL pattern
// HANDLER: function(req, res, next)Objek req (request) berisi semua informasi dari klien:
app.post('/products', (req, res) => {
console.log(req.body); // Body JSON dari request
console.log(req.params.id); // URL parameter: /products/:id
console.log(req.query.page); // Query string: /products?page=2
console.log(req.headers); // HTTP headers
console.log(req.method); // HTTP method
console.log(req.path); // URL path
console.log(req.ip); // IP address klien
});Objek res (response) untuk mengirim response:
app.get('/products', (req, res) => {
// Kirim JSON
res.status(200).json({ data: [] });
// Kirim string
res.send('Hello World');
// Set header
res.set('X-Custom-Header', 'value');
// Redirect
res.redirect('/new-url');
});Router: Modularisasi Routes
Jangan tulis semua route di app.js. Gunakan express.Router() untuk modularisasi:
// src/routes/product.routes.js
import { Router } from 'express';
import {
getAllProducts,
getProductById,
createProduct,
updateProduct,
deleteProduct,
} from '../controllers/product.controller.js';
const router = Router();
router.get('/', getAllProducts);
router.get('/:id', getProductById);
router.post('/', createProduct);
router.put('/:id', updateProduct);
router.delete('/:id', deleteProduct);
export { router as productRouter };API Versioning: Kebiasaan yang Harus Dibangun dari Awal
Sebelum melanjutkan ke controller, ada satu kebiasaan penting yang harus ditanamkan sejak project dibuat: API versioning.
Versioning berarti semua endpoint kamu memiliki prefix versi — /api/v1/products, bukan /products. Kedengarannya seperti overhead yang tidak perlu untuk project baru, tapi ini akan menyelamatkanmu ketika:
- Kamu perlu mengubah struktur response tanpa merusak client yang sudah ada
- Mobile app yang tidak bisa di-force-update masih pakai v1 sementara web sudah pakai v2
- Kamu onboarding developer baru — mereka langsung tahu mana endpoint aktif dan mana yang deprecated
// src/routes/index.js — centralize semua routes dengan versioning
import { Router } from 'express';
import { productRouter } from './product.routes.js';
import { categoryRouter } from './category.routes.js';
const router = Router();
// Semua routes v1 di-mount di sini
router.use('/products', productRouter);
router.use('/categories', categoryRouter);
export { router as v1Router };// src/app.js — mount dengan prefix /api/v1
import { v1Router } from './routes/index.js';
app.use('/api/v1', v1Router);
// Endpoint akhirnya:
// GET /api/v1/products
// POST /api/v1/products
// GET /api/v1/products/:id
// ...Ketika saatnya upgrade ke v2, kamu cukup tambahkan router baru:
import { v1Router } from './routes/v1/index.js';
import { v2Router } from './routes/v2/index.js';
app.use('/api/v1', v1Router); // tetap berjalan, tidak break
app.use('/api/v2', v2Router); // versi baruAturan sederhana: Jika API kamu akan dikonsumsi oleh siapapun selain kamu sendiri — mobile app, frontend lain, third party — versioning bukan opsional.
Controller: Memisahkan Logika
Controller adalah tempat logika bisnis untuk setiap route. Pisahkan dari routes agar kode lebih bersih:
// src/controllers/product.controller.js
import { ProductService } from '../services/product.service.js';
export async function getAllProducts(req, res, next) {
try {
const { page = 1, limit = 10, search } = req.query;
const products = await ProductService.findAll({
page: Number(page),
limit: Number(limit),
search,
});
res.json({
success: true,
data: products.data,
pagination: products.pagination,
});
} catch (error) {
next(error); // Teruskan ke error handler
}
}
export async function getProductById(req, res, next) {
try {
const { id } = req.params;
const product = await ProductService.findById(id);
if (!product) {
return res.status(404).json({
success: false,
message: 'Produk tidak ditemukan',
});
}
res.json({ success: true, data: product });
} catch (error) {
next(error);
}
}
export async function createProduct(req, res, next) {
try {
const product = await ProductService.create(req.body);
res.status(201).json({ success: true, data: product });
} catch (error) {
next(error);
}
}
export async function updateProduct(req, res, next) {
try {
const { id } = req.params;
const product = await ProductService.update(id, req.body);
if (!product) {
return res.status(404).json({
success: false,
message: 'Produk tidak ditemukan',
});
}
res.json({ success: true, data: product });
} catch (error) {
next(error);
}
}
export async function deleteProduct(req, res, next) {
try {
const { id } = req.params;
await ProductService.delete(id);
res.status(204).send();
} catch (error) {
next(error);
}
}Format Response yang Konsisten
Selalu gunakan format response yang sama di seluruh API. Ini memudahkan frontend dalam memproses response.
// Response sukses
{
"success": true,
"data": { ... },
"pagination": { // opsional, untuk list
"page": 1,
"limit": 10,
"total": 100,
"totalPages": 10
}
}
// Response error
{
"success": false,
"message": "Produk tidak ditemukan",
"errors": [ // opsional, untuk validasi
{ "field": "name", "message": "Nama wajib diisi" }
]
}Bab 4: Middleware: Lapisan yang Mengontrol Segalanya
Apa Itu Middleware?
Middleware adalah function yang dieksekusi antara menerima request dan mengirim response. Setiap middleware bisa:
- Menjalankan kode apapun
- Memodifikasi
reqdanres - Mengakhiri request-response cycle
- Memanggil
next()untuk meneruskan ke middleware berikutnya
Request → [Middleware 1] → [Middleware 2] → [Route Handler] → Response
Struktur Middleware
function myMiddleware(req, res, next) {
// Lakukan sesuatu
console.log('Request masuk:', req.method, req.path);
// Teruskan ke middleware/handler berikutnya
next();
// ATAU hentikan di sini dengan mengirim response
// res.status(403).json({ message: 'Dilarang' });
}Middleware Autentikasi
Ini adalah salah satu middleware paling umum — memverifikasi token sebelum request masuk ke handler:
// src/middleware/auth.middleware.js
import jwt from 'jsonwebtoken';
export function authenticate(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({
success: false,
message: 'Token autentikasi diperlukan',
});
}
const token = authHeader.split(' ')[1];
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded; // Inject user info ke request
next();
} catch (error) {
return res.status(401).json({
success: false,
message: 'Token tidak valid atau sudah kadaluarsa',
});
}
}Penggunaan di routes:
import { authenticate } from '../middleware/auth.middleware.js';
// Protect semua routes di router ini
router.use(authenticate);
// Atau protect route tertentu saja
router.post('/', authenticate, createProduct);
router.delete('/:id', authenticate, deleteProduct);Menggunakan req.user di Controller
Setelah authenticate berjalan, req.user berisi payload dari JWT — biasanya id, email, dan role user yang sedang login. Kamu bisa langsung pakai di controller tanpa query ulang ke database:
// src/controllers/product.controller.js
export async function createProduct(req, res, next) {
try {
// req.user sudah di-inject oleh authenticate middleware
const product = await ProductService.create({
...req.body,
createdBy: req.user.id, // siapa yang buat
});
res.status(201).json({ success: true, data: product });
} catch (error) {
next(error);
}
}
export async function deleteProduct(req, res, next) {
try {
const { id } = req.params;
// Contoh: hanya admin yang boleh hapus
if (req.user.role !== 'admin') {
return res.status(403).json({
success: false,
message: 'Hanya admin yang dapat menghapus produk',
});
}
await ProductService.delete(id);
res.status(204).send();
} catch (error) {
next(error);
}
}Optional Authentication
Kadang kamu butuh endpoint yang boleh diakses tanpa login, tapi kalau user login, kamu ingin tahu siapa mereka (misalnya untuk menampilkan harga khusus member):
// src/middleware/auth.middleware.js
export function optionalAuthenticate(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
req.user = null; // tidak login — lanjutkan tanpa reject
return next();
}
const token = authHeader.split(' ')[1];
try {
req.user = jwt.verify(token, process.env.JWT_SECRET);
} catch {
req.user = null; // token invalid — perlakukan seperti tidak login
}
next();
}// routes: endpoint publik yang context-aware
router.get('/', optionalAuthenticate, getAllProducts);
// Di controller: if (req.user) tampilkan harga member, else harga normalBatas scope e-book ini: Middleware di atas cukup untuk memproteksi endpoint dan mengidentifikasi user. Tapi auth yang production-ready membutuhkan lebih — refresh token, token revocation, OAuth2 provider, dan strategi yang aman untuk handle session. Semua itu dibahas tuntas di E-Book #04: Autentikasi Modern: JWT & OAuth2.
Middleware Request Logging
Gunakan Morgan — HTTP request logger yang sudah battle-tested — daripada menulis sendiri dari nol:
npm install morgan// src/app.js
import morgan from 'morgan';
import { config } from './config/index.js';
// Development: format ringkas dan berwarna di terminal
// Production: format JSON yang bisa di-parse oleh log aggregator (Datadog, CloudWatch, dll)
if (config.isDev) {
app.use(morgan('dev'));
} else {
// Custom JSON format untuk production
morgan.token('body', (req) => {
// Jangan log password atau token
const body = { ...req.body };
if (body.password) body.password = '[REDACTED]';
if (body.token) body.token = '[REDACTED]';
return JSON.stringify(body);
});
app.use(
morgan((tokens, req, res) => {
return JSON.stringify({
time: tokens.date(req, res, 'iso'),
method: tokens.method(req, res),
url: tokens.url(req, res),
status: Number(tokens.status(req, res)),
duration_ms: Number(tokens['response-time'](req, res)),
content_length: tokens.res(req, res, 'content-length'),
user_agent: tokens['user-agent'](req, res),
remote_addr: tokens['remote-addr'](req, res),
});
})
);
}Middleware Rate Limiting
Lindungi API dari abuse dengan membatasi jumlah request:
npm install express-rate-limit// src/middleware/rateLimiter.middleware.js
import rateLimit from 'express-rate-limit';
export const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 menit
max: 100, // maks 100 request per window
standardHeaders: true,
legacyHeaders: false,
message: {
success: false,
message: 'Terlalu banyak request. Coba lagi dalam 15 menit.',
},
});
// Rate limiter lebih ketat untuk endpoint sensitif
export const authLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 jam
max: 10, // maks 10 attempt login per jam
message: {
success: false,
message: 'Terlalu banyak percobaan login. Coba lagi dalam 1 jam.',
},
});Bab 5: Database: PostgreSQL dengan node-postgres
Setup Database Connection
// src/db/pool.js
import pg from 'pg';
import 'dotenv/config';
const { Pool } = pg;
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 10, // maksimum koneksi dalam pool
idleTimeoutMillis: 30000, // close idle connection setelah 30 detik
connectionTimeoutMillis: 2000, // timeout jika tidak bisa connect dalam 2 detik
});
// Test koneksi saat startup
pool.on('connect', () => {
console.log('Database connected');
});
pool.on('error', (err) => {
console.error('Unexpected database error:', err);
process.exit(-1);
});
export default pool;Schema Database
-- migrations/001_create_products.sql
CREATE TABLE IF NOT EXISTS products (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
price NUMERIC(12, 2) NOT NULL CHECK (price >= 0),
stock INTEGER NOT NULL DEFAULT 0 CHECK (stock >= 0),
sku VARCHAR(100) UNIQUE,
category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Index untuk query yang sering
CREATE INDEX idx_products_category ON products(category_id);
CREATE INDEX idx_products_sku ON products(sku);
CREATE INDEX idx_products_is_active ON products(is_active);
-- Trigger untuk auto-update updated_at
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER products_updated_at
BEFORE UPDATE ON products
FOR EACH ROW
EXECUTE FUNCTION update_updated_at();Product Service: Query yang Benar
// src/services/product.service.js
import pool from '../db/pool.js';
export const ProductService = {
async findAll({ page = 1, limit = 10, search, categoryId, isActive = true }) {
const offset = (page - 1) * limit;
const conditions = ['p.is_active = $1'];
const params = [isActive];
let paramCount = 2;
if (search) {
conditions.push(`(p.name ILIKE $${paramCount} OR p.description ILIKE $${paramCount})`);
params.push(`%${search}%`);
paramCount++;
}
if (categoryId) {
conditions.push(`p.category_id = $${paramCount}`);
params.push(categoryId);
paramCount++;
}
const whereClause = conditions.join(' AND ');
// Query untuk data
const dataQuery = `
SELECT
p.id,
p.name,
p.description,
p.price,
p.stock,
p.sku,
c.name AS category_name,
p.created_at
FROM products p
LEFT JOIN categories c ON p.category_id = c.id
WHERE ${whereClause}
ORDER BY p.created_at DESC
LIMIT $${paramCount} OFFSET $${paramCount + 1}
`;
params.push(limit, offset);
// Query untuk total count (tanpa limit/offset)
const countQuery = `
SELECT COUNT(*) as total
FROM products p
WHERE ${whereClause}
`;
const [dataResult, countResult] = await Promise.all([
pool.query(dataQuery, params),
pool.query(countQuery, params.slice(0, -2)), // tanpa limit & offset
]);
const total = parseInt(countResult.rows[0].total);
return {
data: dataResult.rows,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
};
},
async findById(id) {
const result = await pool.query(
`SELECT p.*, c.name AS category_name
FROM products p
LEFT JOIN categories c ON p.category_id = c.id
WHERE p.id = $1`,
[id]
);
return result.rows[0] || null;
},
async create({ name, description, price, stock, sku, categoryId }) {
const result = await pool.query(
`INSERT INTO products (name, description, price, stock, sku, category_id)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *`,
[name, description, price, stock, sku, categoryId]
);
return result.rows[0];
},
async update(id, { name, description, price, stock, sku, categoryId }) {
// Hanya update field yang dikirim (PATCH behavior)
const updates = [];
const params = [];
let paramCount = 1;
if (name !== undefined) { updates.push(`name = $${paramCount++}`); params.push(name); }
if (description !== undefined) { updates.push(`description = $${paramCount++}`); params.push(description); }
if (price !== undefined) { updates.push(`price = $${paramCount++}`); params.push(price); }
if (stock !== undefined) { updates.push(`stock = $${paramCount++}`); params.push(stock); }
if (sku !== undefined) { updates.push(`sku = $${paramCount++}`); params.push(sku); }
if (categoryId !== undefined) { updates.push(`category_id = $${paramCount++}`); params.push(categoryId); }
if (updates.length === 0) return this.findById(id);
params.push(id);
const result = await pool.query(
`UPDATE products SET ${updates.join(', ')} WHERE id = $${paramCount} RETURNING *`,
params
);
return result.rows[0] || null;
},
async delete(id) {
// Soft delete: set is_active = false
const result = await pool.query(
`UPDATE products SET is_active = false WHERE id = $1 RETURNING id`,
[id]
);
if (result.rows.length === 0) {
throw new Error('Produk tidak ditemukan');
}
},
};Format Response Pagination yang Konsisten
findAll sudah mengembalikan data dan pagination object. Sekarang pastikan controller membungkusnya dalam response envelope yang konsisten — dan buat helper agar tidak menulis ulang struktur ini di setiap endpoint:
// src/utils/response.js
export function successResponse(res, data, statusCode = 200) {
return res.status(statusCode).json({ success: true, data });
}
export function paginatedResponse(res, result) {
return res.json({
success: true,
data: result.data,
meta: {
page: result.pagination.page,
limit: result.pagination.limit,
total: result.pagination.total,
totalPages: result.pagination.totalPages,
hasNextPage: result.pagination.page < result.pagination.totalPages,
hasPrevPage: result.pagination.page > 1,
},
});
}Penggunaan di controller:
import { paginatedResponse, successResponse } from '../utils/response.js';
export async function getAllProducts(req, res, next) {
try {
const result = await ProductService.findAll(req.query);
return paginatedResponse(res, result);
} catch (error) {
next(error);
}
}
export async function getProductById(req, res, next) {
try {
const product = await ProductService.findById(req.params.id);
if (!product) {
return res.status(404).json({ success: false, message: 'Produk tidak ditemukan' });
}
return successResponse(res, product);
} catch (error) {
next(error);
}
}Response yang diterima frontend:
{
"success": true,
"data": [ ... ],
"meta": {
"page": 2,
"limit": 10,
"total": 47,
"totalPages": 5,
"hasNextPage": true,
"hasPrevPage": true
}
}hasNextPage dan hasPrevPage adalah bonus kecil yang sangat dihargai oleh frontend developer — mereka tidak perlu menghitung sendiri.
Transactions: Operasi Atomik
Gunakan transactions ketika beberapa operasi database harus berhasil semua atau gagal semua:
export async function transferStock(fromProductId, toProductId, quantity) {
const client = await pool.connect();
try {
await client.query('BEGIN');
// Kurangi stok produk asal
const fromResult = await client.query(
`UPDATE products SET stock = stock - $1 WHERE id = $2 AND stock >= $1 RETURNING stock`,
[quantity, fromProductId]
);
if (fromResult.rows.length === 0) {
throw new Error('Stok tidak cukup');
}
// Tambah stok produk tujuan
await client.query(
`UPDATE products SET stock = stock + $1 WHERE id = $2`,
[quantity, toProductId]
);
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release(); // SELALU release client kembali ke pool
}
}Bab 6: Validasi Input dengan Zod
Mengapa Validasi Input Penting
Jangan pernah mempercayai input dari client. Tanpa validasi:
- User bisa kirim
price: -9999dan merusak data - User bisa kirim string XSS atau SQL injection
- Bug sulit di-debug karena data yang tidak konsisten
Setup Zod Schema
// src/schemas/product.schema.js
import { z } from 'zod';
export const createProductSchema = z.object({
name: z
.string({ required_error: 'Nama produk wajib diisi' })
.min(3, 'Nama minimal 3 karakter')
.max(255, 'Nama maksimal 255 karakter')
.trim(),
description: z
.string()
.max(2000, 'Deskripsi maksimal 2000 karakter')
.optional(),
price: z
.number({ required_error: 'Harga wajib diisi', invalid_type_error: 'Harga harus berupa angka' })
.min(0, 'Harga tidak boleh negatif')
.multipleOf(0.01, 'Harga maksimal 2 desimal'),
stock: z
.number({ invalid_type_error: 'Stok harus berupa bilangan bulat' })
.int('Stok harus bilangan bulat')
.min(0, 'Stok tidak boleh negatif')
.default(0),
sku: z
.string()
.regex(/^[A-Z0-9-]+$/, 'SKU hanya boleh mengandung huruf kapital, angka, dan tanda hubung')
.optional(),
categoryId: z
.number({ invalid_type_error: 'Category ID harus berupa angka' })
.int()
.positive()
.optional()
.nullable(),
});
// Schema untuk update — semua field opsional
export const updateProductSchema = createProductSchema.partial();
// Schema untuk query parameters
export const productQuerySchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(100).default(10),
search: z.string().optional(),
categoryId: z.coerce.number().int().positive().optional(),
});Middleware Validasi
// src/middleware/validate.middleware.js
export function validate(schema) {
return (req, res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) {
const errors = result.error.errors.map((err) => ({
field: err.path.join('.'),
message: err.message,
}));
return res.status(400).json({
success: false,
message: 'Validasi input gagal',
errors,
});
}
req.body = result.data; // Ganti body dengan data yang sudah divalidasi & di-transform
next();
};
}
// Untuk validasi query params
export function validateQuery(schema) {
return (req, res, next) => {
const result = schema.safeParse(req.query);
if (!result.success) {
const errors = result.error.errors.map((err) => ({
field: err.path.join('.'),
message: err.message,
}));
return res.status(400).json({
success: false,
message: 'Query parameter tidak valid',
errors,
});
}
req.query = result.data;
next();
};
}Gunakan di routes:
import { validate, validateQuery } from '../middleware/validate.middleware.js';
import { createProductSchema, productQuerySchema } from '../schemas/product.schema.js';
router.get('/', validateQuery(productQuerySchema), getAllProducts);
router.post('/', validate(createProductSchema), createProduct);Bab 7: Error Handling yang Konsisten
Custom Error Classes
// src/utils/AppError.js
export class AppError extends Error {
constructor(message, statusCode = 500, errors = []) {
super(message);
this.statusCode = statusCode;
this.errors = errors;
this.isOperational = true; // Error yang kita throw dengan sengaja
Error.captureStackTrace(this, this.constructor);
}
}
export class NotFoundError extends AppError {
constructor(resource = 'Resource') {
super(`${resource} tidak ditemukan`, 404);
}
}
export class ConflictError extends AppError {
constructor(message) {
super(message, 409);
}
}
export class ForbiddenError extends AppError {
constructor(message = 'Tidak memiliki izin') {
super(message, 403);
}
}Global Error Handler Middleware
// src/middleware/error.middleware.js
import { AppError } from '../utils/AppError.js';
export function errorHandler(err, req, res, next) {
// Log error
if (process.env.NODE_ENV !== 'test') {
console.error(`[ERROR] ${new Date().toISOString()} | ${req.method} ${req.path}`);
console.error(err);
}
// Error yang kita throw dengan sengaja (AppError)
if (err instanceof AppError) {
return res.status(err.statusCode).json({
success: false,
message: err.message,
...(err.errors.length > 0 && { errors: err.errors }),
});
}
// Error dari PostgreSQL
if (err.code === '23505') { // unique_violation
return res.status(409).json({
success: false,
message: 'Data sudah ada (duplikat)',
});
}
if (err.code === '23503') { // foreign_key_violation
return res.status(400).json({
success: false,
message: 'Referensi data tidak valid',
});
}
// Error tak terduga — jangan ekspos detail ke client
res.status(500).json({
success: false,
message: 'Terjadi kesalahan di server',
...(process.env.NODE_ENV === 'development' && { detail: err.message }),
});
}
export function notFoundHandler(req, res) {
res.status(404).json({
success: false,
message: `Endpoint ${req.method} ${req.path} tidak ditemukan`,
});
}Async Error Wrapper
Daripada try/catch di setiap controller, gunakan wrapper:
// src/utils/asyncHandler.js
export const asyncHandler = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};Penggunaan yang lebih bersih:
import { asyncHandler } from '../utils/asyncHandler.js';
import { NotFoundError } from '../utils/AppError.js';
export const getProductById = asyncHandler(async (req, res) => {
const product = await ProductService.findById(req.params.id);
if (!product) {
throw new NotFoundError('Produk');
}
res.json({ success: true, data: product });
});Bab 8: Struktur Folder Production-Ready
Struktur yang Scalable
toko-api/
├── src/
│ ├── app.js # Express app setup (no listen)
│ ├── server.js # Entry point (listen)
│ ├── controllers/
│ │ ├── product.controller.js
│ │ └── category.controller.js
│ ├── services/
│ │ ├── product.service.js # Business logic + DB queries
│ │ └── category.service.js
│ ├── routes/
│ │ ├── index.js # Combine all routers
│ │ ├── product.routes.js
│ │ └── category.routes.js
│ ├── middleware/
│ │ ├── auth.middleware.js
│ │ ├── validate.middleware.js
│ │ ├── error.middleware.js
│ │ └── rateLimiter.middleware.js
│ ├── schemas/
│ │ ├── product.schema.js # Zod schemas
│ │ └── category.schema.js
│ ├── db/
│ │ └── pool.js # Database connection pool
│ └── utils/
│ ├── AppError.js
│ └── asyncHandler.js
├── migrations/
│ ├── 001_create_categories.sql
│ └── 002_create_products.sql
├── tests/
│ ├── integration/
│ │ └── product.test.js
│ └── unit/
│ └── product.service.test.js
├── .env
├── .env.example # Template .env yang bisa di-commit
├── .gitignore
├── package.json
└── README.md
Pemisahan app.js dan server.js
Pisahkan Express app dari logic menjalankan server. Ini memudahkan testing:
// src/server.js — entry point sesungguhnya
import app from './app.js';
import pool from './db/pool.js';
const PORT = process.env.PORT || 3000;
async function startServer() {
// Verifikasi koneksi database sebelum menerima request
try {
await pool.query('SELECT 1');
console.log('Database connection OK');
} catch (error) {
console.error('Database connection failed:', error);
process.exit(1);
}
app.listen(PORT, () => {
console.log(`Server berjalan di http://localhost:${PORT}`);
console.log(`Environment: ${process.env.NODE_ENV}`);
});
}
startServer();Route Index: Centralize Semua Routes
// src/routes/index.js
import { Router } from 'express';
import { productRouter } from './product.routes.js';
import { categoryRouter } from './category.routes.js';
const router = Router();
router.use('/products', productRouter);
router.use('/categories', categoryRouter);
export default router;// src/app.js
import apiRouter from './routes/index.js';
app.use('/api/v1', apiRouter);Bab 9: Environment, Config & Security Dasar
.env.example: Dokumentasi Environment
# .env.example — file ini BOLEH di-commit, tidak berisi nilai sensitif
PORT=3000
NODE_ENV=development
# Database
DATABASE_URL=postgresql://USER:PASSWORD@HOST:PORT/DB_NAME
# JWT (gunakan string acak yang panjang di production)
JWT_SECRET=your-super-secret-key-min-32-chars
JWT_EXPIRES_IN=7d
# Rate limiting
RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX=100Centralized Config
// src/config/index.js
export const config = {
port: process.env.PORT || 3000,
env: process.env.NODE_ENV || 'development',
isDev: process.env.NODE_ENV === 'development',
isProd: process.env.NODE_ENV === 'production',
db: {
url: process.env.DATABASE_URL,
},
jwt: {
secret: process.env.JWT_SECRET,
expiresIn: process.env.JWT_EXPIRES_IN || '7d',
},
rateLimit: {
windowMs: Number(process.env.RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000,
max: Number(process.env.RATE_LIMIT_MAX) || 100,
},
};
// Validasi environment variables wajib
const requiredEnvVars = ['DATABASE_URL', 'JWT_SECRET'];
const missingVars = requiredEnvVars.filter((v) => !process.env[v]);
if (missingVars.length > 0) {
console.error(`Missing required environment variables: ${missingVars.join(', ')}`);
process.exit(1);
}Security Headers dengan Helmet
import helmet from 'helmet';
// Konfigurasi helmet yang lebih spesifik
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
},
},
})
);CORS Configuration
import cors from 'cors';
const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(',') || ['http://localhost:3001'];
app.use(
cors({
origin: (origin, callback) => {
// Izinkan request tanpa origin (Postman, mobile apps)
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
})
);Health Check Endpoint
Health check adalah endpoint sederhana yang digunakan oleh load balancer, Docker, atau monitoring tool untuk tahu apakah service kamu hidup dan sehat. Tanpa ini, infrastructure tidak bisa membedakan antara "server hidup tapi database-nya mati" dan "server benar-benar down".
// src/routes/health.routes.js
import { Router } from 'express';
import pool from '../db/pool.js';
const router = Router();
// Basic health check — apakah server bisa respond?
router.get('/', (req, res) => {
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
environment: process.env.NODE_ENV,
});
});
// Deep health check — apakah dependency (database) juga sehat?
router.get('/db', async (req, res) => {
try {
const start = Date.now();
await pool.query('SELECT 1');
const latency = Date.now() - start;
res.json({
status: 'ok',
database: {
status: 'connected',
latency_ms: latency,
},
});
} catch (error) {
res.status(503).json({
status: 'error',
database: {
status: 'disconnected',
message: error.message,
},
});
}
});
export { router as healthRouter };Mount di app.js — di luar prefix /api/v1 dan tanpa auth middleware:
import { healthRouter } from './routes/health.routes.js';
app.use('/health', healthRouter);
// GET /health → basic check
// GET /health/db → database checkUntuk Docker, tambahkan di Dockerfile:
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -f http://localhost:3000/health || exit 1SQL Injection: Selalu Gunakan Parameterized Query
// ✗ SALAH — rentan SQL injection
const name = req.query.name;
pool.query(`SELECT * FROM products WHERE name = '${name}'`);
// Jika name = "' OR '1'='1", query menjadi:
// SELECT * FROM products WHERE name = '' OR '1'='1'
// → mengembalikan semua produk!
// ✓ BENAR — gunakan parameterized query
pool.query('SELECT * FROM products WHERE name = $1', [name]);
// Nilai name diperlakukan sebagai data, bukan SQL codeAturan nomor satu: Jangan pernah interpolasi input user langsung ke query string. Selalu gunakan parameter placeholder (
$1,$2, dst).
Bab 10: Upload File dengan Multer
Hampir semua API yang dipakai di production butuh kemampuan menerima file — foto produk, dokumen, avatar user. Node.js tidak punya ini built-in; kita gunakan Multer, middleware yang paling banyak dipakai untuk handle multipart/form-data.
npm install multerDisk Storage vs Memory Storage
Multer punya dua mode penyimpanan:
// src/config/multer.js
import multer from 'multer';
import path from 'path';
import { randomUUID } from 'crypto';
// ── Disk Storage — simpan langsung ke folder lokal ──────────────────────────
// Cocok untuk development atau VPS dengan storage lokal
const diskStorage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, 'uploads/products/'); // pastikan folder ini ada
},
filename: (req, file, cb) => {
// Jangan pakai nama file asli dari user — bisa mengandung karakter berbahaya
const ext = path.extname(file.originalname).toLowerCase();
cb(null, `${randomUUID()}${ext}`);
},
});
// ── Memory Storage — simpan di memory sebagai Buffer ────────────────────────
// Cocok untuk langsung di-upload ke cloud storage (S3, R2, dsb)
const memoryStorage = multer.memoryStorage();Validasi Tipe dan Ukuran File
// src/config/multer.js (lanjutan)
const imageFileFilter = (req, file, cb) => {
const allowedMimeTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'];
if (allowedMimeTypes.includes(file.mimetype)) {
cb(null, true); // terima file
} else {
cb(new Error('Hanya file gambar yang diperbolehkan (JPEG, PNG, WebP)'), false);
}
};
export const uploadProductImage = multer({
storage: diskStorage,
fileFilter: imageFileFilter,
limits: {
fileSize: 2 * 1024 * 1024, // maks 2MB
files: 1, // maks 1 file per request
},
});
// Untuk multiple files (misal: galeri produk)
export const uploadProductGallery = multer({
storage: diskStorage,
fileFilter: imageFileFilter,
limits: {
fileSize: 2 * 1024 * 1024,
files: 5, // maks 5 foto sekaligus
},
});Integrasi ke Route
// src/routes/product.routes.js
import { uploadProductImage, uploadProductGallery } from '../config/multer.js';
// Upload satu foto
router.post('/:id/image',
authenticate,
uploadProductImage.single('image'), // 'image' = nama field di form-data
uploadProductImageController
);
// Upload galeri
router.post('/:id/gallery',
authenticate,
uploadProductGallery.array('images', 5),
uploadProductGalleryController
);Controller Upload
// src/controllers/product.controller.js
export async function uploadProductImageController(req, res, next) {
try {
if (!req.file) {
return res.status(400).json({
success: false,
message: 'File gambar tidak ditemukan dalam request',
});
}
const imageUrl = `/uploads/products/${req.file.filename}`;
const product = await ProductService.update(req.params.id, {
imageUrl,
});
res.json({
success: true,
data: {
imageUrl,
filename: req.file.filename,
size: req.file.size,
mimetype: req.file.mimetype,
},
});
} catch (error) {
next(error);
}
}Error Handling Khusus Multer
Error dari Multer tidak otomatis tertangkap oleh global error handler Express. Kamu perlu handle secara eksplisit:
// src/middleware/errorHandler.middleware.js
import multer from 'multer';
export function errorHandler(err, req, res, next) {
// Handle Multer errors khusus
if (err instanceof multer.MulterError) {
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({
success: false,
message: 'Ukuran file terlalu besar. Maksimal 2MB.',
});
}
if (err.code === 'LIMIT_FILE_COUNT') {
return res.status(400).json({
success: false,
message: 'Terlalu banyak file. Maksimal 5 file.',
});
}
return res.status(400).json({ success: false, message: err.message });
}
// Handle custom fileFilter error
if (err.message?.includes('Hanya file gambar')) {
return res.status(400).json({ success: false, message: err.message });
}
// ... rest of error handling
}Serve Static Files
Agar gambar yang sudah diupload bisa diakses via URL:
// src/app.js
import { fileURLToPath } from 'url';
import path from 'path';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Serve folder uploads sebagai static files
app.use('/uploads', express.static(path.join(__dirname, '..', 'uploads')));
// Akses gambar via: GET /uploads/products/uuid.jpgUntuk production: Disk storage di VPS langsung tidak ideal jangka panjang — storage terbatas, tidak bisa scale horizontal, dan file hilang saat redeploy container. Solusi yang proper adalah upload ke cloud storage (Cloudflare R2, AWS S3, Google Cloud Storage). Pola-nya sama — Multer dengan memory storage, lalu stream buffer ke cloud SDK. Ini akan dibahas lebih dalam di e-book deployment.
Penutup
Kamu baru saja membangun REST API yang benar-benar production-ready — bukan sekadar CRUD tutorial. Mari lihat apa yang sudah ada di tangan kamu sekarang.
Struktur Project Final
src/
├── app.js # Express setup, middleware stack
├── server.js # Entry point, listen port
├── config/
│ ├── index.js # Centralized config + env validation
│ └── multer.js # File upload configuration
├── db/
│ └── pool.js # PostgreSQL connection pool
├── routes/
│ ├── index.js # v1Router — semua routes terpusat
│ └── health.routes.js # Health check endpoints
├── controllers/
│ └── product.controller.js # Request/response handling
├── services/
│ └── product.service.js # Business logic + database queries
├── middleware/
│ ├── auth.middleware.js # JWT authentication
│ ├── validate.middleware.js # Input validation dengan Zod
│ ├── rateLimiter.middleware.js
│ └── errorHandler.middleware.js
├── schemas/
│ └── product.schema.js # Zod schemas
└── utils/
└── response.js # Response helpers (paginatedResponse, dsb)
uploads/
└── products/ # File upload storage (lokal)
migrations/
└── 001_create_products.sql
Endpoint yang Sudah Dibangun
GET /health → Basic health check
GET /health/db → Database health check
GET /api/v1/products → List produk (dengan pagination + search)
POST /api/v1/products → Buat produk baru [auth required]
GET /api/v1/products/:id → Detail satu produk
PUT /api/v1/products/:id → Update produk [auth required]
DELETE /api/v1/products/:id → Soft delete produk [auth required, admin]
POST /api/v1/products/:id/image → Upload foto produk [auth required]
Production Checklist Sebelum Deploy
Sebelum API ini benar-benar dipakai orang lain, pastikan:
- Semua environment variable terdokumentasi di
.env.example -
NODE_ENV=productiondi server - JWT_SECRET minimal 32 karakter acak (bukan "secret" atau "password")
- Database URL menggunakan SSL (
?sslmode=requireuntuk managed DB) - Rate limiting aktif
- Helmet terpasang
- CORS hanya mengizinkan origin yang dikenal
-
/healthendpoint bisa diakses tanpa auth - File upload punya size limit dan file type validation
- Parameterized query di semua database call (tidak ada string interpolation)
- Error handler global menangkap semua
next(error)calls
Langkah selanjutnya:
- Testing — unit test dan integration test untuk setiap endpoint (e-book #09)
- Autentikasi lengkap — refresh token, revocation, OAuth2 (e-book #04)
- Deploy — Docker, Nginx, SSL, PM2 di VPS (e-book #05 + #07)
Kode lengkap project ini akan tersedia di repository publik — tautannya menyusul.