Lewati ke konten utama
Semua artikel
MenengahNode.js

Membangun REST API dengan Node.js & Express

Dari setup project, routing, middleware, validasi, error handling, sampai struktur folder production-ready.

29 menit baca

"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

  1. REST & HTTP: Fondasi yang Harus Kamu Pahami
  2. Setup Project Node.js yang Benar
  3. Express: Routing & Request Handling
  4. Middleware: Lapisan yang Mengontrol Segalanya
  5. Database: PostgreSQL dengan node-postgres
  6. Validasi Input dengan Zod
  7. Error Handling yang Konsisten
  8. Struktur Folder Production-Ready
  9. Environment, Config & Security Dasar
  10. 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

MethodFungsiIdempotent?
GETAmbil dataYa
POSTBuat data baruTidak
PUTUpdate keseluruhanYa
PATCHUpdate sebagianTidak
DELETEHapus dataYa

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:

  1. Client-Server: frontend dan backend terpisah
  2. Stateless: setiap request mengandung semua informasi yang dibutuhkan. Server tidak menyimpan state antar request.
  3. Cacheable: response bisa di-cache
  4. 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 -y

Install 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 formatter

Setup 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 baru

Aturan 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:

  1. Menjalankan kode apapun
  2. Memodifikasi req dan res
  3. Mengakhiri request-response cycle
  4. 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 normal

Batas 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: -9999 dan 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=100

Centralized 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.jsdi 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 check

Untuk Docker, tambahkan di Dockerfile:

HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
  CMD curl -f http://localhost:3000/health || exit 1

SQL 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 code

Aturan 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 multer

Disk 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.jpg

Untuk 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=production di server
  • JWT_SECRET minimal 32 karakter acak (bukan "secret" atau "password")
  • Database URL menggunakan SSL (?sslmode=require untuk managed DB)
  • Rate limiting aktif
  • Helmet terpasang
  • CORS hanya mengizinkan origin yang dikenal
  • /health endpoint 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:

  1. Testing — unit test dan integration test untuk setiap endpoint (e-book #09)
  2. Autentikasi lengkap — refresh token, revocation, OAuth2 (e-book #04)
  3. Deploy — Docker, Nginx, SSL, PM2 di VPS (e-book #05 + #07)

Kode lengkap project ini akan tersedia di repository publik — tautannya menyusul.

Lanjutkan ke

Testing REST API dengan Jest & SupertestSegera
Autentikasi Modern: JWT & OAuth2Docker untuk Backend Developer
Deploy Backend ke VPS: Ubuntu + Nginx + SSLSegera
Integrasi LLM API untuk Backend EngineerSegera