Studi Kasus #5: Aplikasi Wisata Indonesia – Membuat API untuk Tempat Wisata (Destinations)


Studi Kasus #5: Aplikasi Wisata Indonesia – Membuat API untuk Tempat Wisata (Destinations)

Halo, calon full-stack developer! 👋

Di Studi Kasus #4, kita telah menyelesaikan API untuk kategori. Sekarang saatnya kita membuat API untuk tempat wisata (destinations). API ini akan menjadi inti dari aplikasi kita karena menampilkan data utama yang akan dilihat pengguna.

Fitur yang akan kita buat:

  • CRUD lengkap untuk data tempat wisata.
  • Relasi dengan kategori (setiap destinasi memiliki satu kategori).
  • Upload gambar cover untuk destinasi menggunakan multer.
  • Mengambil data destinasi beserta informasi kategori (join).

Kita akan menguji semua endpoint menggunakan Postman. Yuk, mulai! 🚀

📁 Struktur Folder dan File

Sebelum memulai, pastikan struktur folder backend kita sudah rapi. Kita akan menambahkan folder uploads/ untuk menyimpan gambar, dan beberapa file baru:

backend/
├── config/
│   └── db.js
├── controllers/
│   ├── categoryController.js
│   └── destinationController.js  (baru)
├── middlewares/
│   └── upload.js                 (baru, untuk konfigurasi multer)
├── routes/
│   ├── categoryRoutes.js
│   └── destinationRoutes.js      (baru)
├── uploads/                       (folder untuk menyimpan gambar)
├── .env
├── app.js
└── package.json

Buat folder middlewares dan uploads di dalam backend. Kita juga akan membuat file-file yang diperlukan.

Konfigurasi Multer untuk Upload Gambar

Multer adalah middleware Express untuk menangani multipart/form-data (upload file). Kita akan konfigurasi agar file gambar disimpan di folder uploads/ dengan nama yang unik.

Buat file middlewares/upload.js:

const multer = require('multer');
const path = require('path');

// Konfigurasi penyimpanan
const storage = multer.diskStorage({
  destination: (req, file, cb) => {
    cb(null, 'uploads/'); // folder tujuan
  },
  filename: (req, file, cb) => {
    // Buat nama unik: timestamp + random + ekstensi
    const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
    cb(null, file.fieldname + '-' + uniqueSuffix + path.extname(file.originalname));
  }
});

// Filter file (hanya gambar yang diperbolehkan)
const fileFilter = (req, file, cb) => {
  if (file.mimetype.startsWith('image/')) {
    cb(null, true);
  } else {
    cb(new Error('File harus berupa gambar'), false);
  }
};

// Buat middleware upload
const upload = multer({
  storage: storage,
  fileFilter: fileFilter,
  limits: { fileSize: 2 * 1024 * 1024 } // maks 2MB
});

module.exports = upload;

Penjelasan:

  • diskStorage menentukan di mana file disimpan dan bagaimana penamaannya.
  • Nama file dibuat unik agar tidak tertimpa.
  • fileFilter memastikan hanya file gambar yang diupload.
  • limits membatasi ukuran file (2MB).

Kita akan menggunakan middleware ini di route yang membutuhkan upload.

Membuat Destination Controller

Buat file controllers/destinationController.js. Ini akan berisi fungsi-fungsi CRUD untuk destinations.

const db = require('../config/db');
const fs = require('fs');
const path = require('path');

// GET semua destinasi (dengan informasi kategori)
const getAllDestinations = async (req, res) => {
  try {
    // Query dengan JOIN ke tabel categories
    const query = `
      SELECT d.*, c.name as category_name, c.slug as category_slug
      FROM destinations d
      LEFT JOIN categories c ON d.category_id = c.id
      ORDER BY d.id DESC
    `;
    const [rows] = await db.query(query);
    res.json(rows);
  } catch (err) {
    console.error(err);
    res.status(500).json({ error: 'Gagal mengambil data destinasi' });
  }
};

// GET satu destinasi berdasarkan ID (dengan kategori)
const getDestinationById = async (req, res) => {
  const { id } = req.params;
  try {
    const query = `
      SELECT d.*, c.name as category_name, c.slug as category_slug
      FROM destinations d
      LEFT JOIN categories c ON d.category_id = c.id
      WHERE d.id = ?
    `;
    const [rows] = await db.query(query, [id]);
    if (rows.length === 0) {
      return res.status(404).json({ error: 'Destinasi tidak ditemukan' });
    }
    res.json(rows[0]);
  } catch (err) {
    console.error(err);
    res.status(500).json({ error: 'Gagal mengambil data destinasi' });
  }
};

// POST tambah destinasi baru (dengan upload cover)
const createDestination = async (req, res) => {
  const { category_id, name, slug, location, description, facilities } = req.body;
  const coverImage = req.file ? req.file.filename : null; // nama file yang diupload

  // Validasi sederhana
  if (!category_id || !name || !slug || !location) {
    return res.status(400).json({ error: 'category_id, name, slug, dan location harus diisi' });
  }

  try {
    // Cek apakah slug sudah digunakan
    const [existing] = await db.query('SELECT id FROM destinations WHERE slug = ?', [slug]);
    if (existing.length > 0) {
      return res.status(400).json({ error: 'Slug sudah digunakan, gunakan slug lain' });
    }

    const [result] = await db.query(
      `INSERT INTO destinations (category_id, name, slug, location, description, facilities, cover_image)
       VALUES (?, ?, ?, ?, ?, ?, ?)`,
      [category_id, name, slug, location, description, facilities, coverImage]
    );

    // Ambil data yang baru saja dimasukkan
    const [newDestination] = await db.query(
      `SELECT d.*, c.name as category_name
       FROM destinations d
       LEFT JOIN categories c ON d.category_id = c.id
       WHERE d.id = ?`,
      [result.insertId]
    );

    res.status(201).json(newDestination[0]);
  } catch (err) {
    console.error(err);
    // Hapus file yang sudah terupload jika terjadi error
    if (req.file) {
      fs.unlinkSync(path.join(__dirname, '../uploads', req.file.filename));
    }
    res.status(500).json({ error: 'Gagal menambah destinasi' });
  }
};

// PUT update destinasi
const updateDestination = async (req, res) => {
  const { id } = req.params;
  const { category_id, name, slug, location, description, facilities } = req.body;
  const coverImage = req.file ? req.file.filename : null;

  try {
    // Ambil data lama untuk mendapatkan cover lama
    const [oldData] = await db.query('SELECT cover_image FROM destinations WHERE id = ?', [id]);
    if (oldData.length === 0) {
      return res.status(404).json({ error: 'Destinasi tidak ditemukan' });
    }

    // Cek slug duplikat (kecuali dirinya sendiri)
    if (slug) {
      const [existing] = await db.query('SELECT id FROM destinations WHERE slug = ? AND id != ?', [slug, id]);
      if (existing.length > 0) {
        return res.status(400).json({ error: 'Slug sudah digunakan, gunakan slug lain' });
      }
    }

    // Bangun query update dinamis (hanya field yang dikirim)
    let fields = [];
    let values = [];

    if (category_id) { fields.push('category_id = ?'); values.push(category_id); }
    if (name) { fields.push('name = ?'); values.push(name); }
    if (slug) { fields.push('slug = ?'); values.push(slug); }
    if (location) { fields.push('location = ?'); values.push(location); }
    if (description !== undefined) { fields.push('description = ?'); values.push(description); }
    if (facilities !== undefined) { fields.push('facilities = ?'); values.push(facilities); }
    if (coverImage) { fields.push('cover_image = ?'); values.push(coverImage); }

    if (fields.length === 0) {
      return res.status(400).json({ error: 'Tidak ada data yang diupdate' });
    }

    values.push(id);
    const query = `UPDATE destinations SET ${fields.join(', ')} WHERE id = ?`;
    const [result] = await db.query(query, values);

    if (result.affectedRows === 0) {
      return res.status(404).json({ error: 'Destinasi tidak ditemukan' });
    }

    // Jika upload gambar baru, hapus gambar lama
    if (coverImage && oldData[0].cover_image) {
      const oldPath = path.join(__dirname, '../uploads', oldData[0].cover_image);
      if (fs.existsSync(oldPath)) {
        fs.unlinkSync(oldPath);
      }
    }

    // Ambil data terbaru
    const [updated] = await db.query(
      `SELECT d.*, c.name as category_name
       FROM destinations d
       LEFT JOIN categories c ON d.category_id = c.id
       WHERE d.id = ?`,
      [id]
    );
    res.json(updated[0]);
  } catch (err) {
    console.error(err);
    // Hapus file baru jika error
    if (req.file) {
      fs.unlinkSync(path.join(__dirname, '../uploads', req.file.filename));
    }
    res.status(500).json({ error: 'Gagal mengupdate destinasi' });
  }
};

// DELETE hapus destinasi
const deleteDestination = async (req, res) => {
  const { id } = req.params;
  try {
    // Ambil data untuk mendapatkan cover_image
    const [data] = await db.query('SELECT cover_image FROM destinations WHERE id = ?', [id]);
    if (data.length === 0) {
      return res.status(404).json({ error: 'Destinasi tidak ditemukan' });
    }

    const [result] = await db.query('DELETE FROM destinations WHERE id = ?', [id]);

    // Hapus file cover dari folder uploads
    if (data[0].cover_image) {
      const filePath = path.join(__dirname, '../uploads', data[0].cover_image);
      if (fs.existsSync(filePath)) {
        fs.unlinkSync(filePath);
      }
    }

    res.json({ message: 'Destinasi berhasil dihapus' });
  } catch (err) {
    console.error(err);
    res.status(500).json({ error: 'Gagal menghapus destinasi' });
  }
};

module.exports = {
  getAllDestinations,
  getDestinationById,
  createDestination,
  updateDestination,
  deleteDestination
};

Penjelasan:

  • Pada getAllDestinations dan getDestinationById, kita melakukan LEFT JOIN dengan tabel categories untuk mendapatkan nama kategori.
  • Pada createDestination, kita menyimpan nama file gambar cover (jika ada) ke database. Jika terjadi error, file yang sudah terupload akan dihapus.
  • Pada updateDestination, kita membangun query dinamis berdasarkan field yang dikirim. Jika ada upload gambar baru, gambar lama dihapus.
  • Pada deleteDestination, kita hapus juga file cover dari folder uploads.
  • Kita menggunakan modul fs untuk menghapus file.

Membuat Destination Routes

Buat file routes/destinationRoutes.js:

const express = require('express');
const router = express.Router();
const destinationController = require('../controllers/destinationController');
const upload = require('../middlewares/upload');

// GET semua destinasi
router.get('/', destinationController.getAllDestinations);

// GET satu destinasi
router.get('/:id', destinationController.getDestinationById);

// POST tambah destinasi (dengan upload cover)
router.post('/', upload.single('cover_image'), destinationController.createDestination);

// PUT update destinasi (dengan upload cover opsional)
router.put('/:id', upload.single('cover_image'), destinationController.updateDestination);

// DELETE hapus destinasi
router.delete('/:id', destinationController.deleteDestination);

module.exports = router;

Perhatikan penggunaan upload.single('cover_image') pada route POST dan PUT. Ini berarti kita mengharapkan file dengan field name cover_image dikirim melalui form-data.

Di app.js, daftarkan route ini:

const destinationRoutes = require('./routes/destinationRoutes');
app.use('/api/destinations', destinationRoutes);

Jangan lupa juga untuk menyajikan folder uploads sebagai file statis agar gambar bisa diakses melalui URL. Tambahkan ini di app.js setelah middleware lainnya:

app.use('/uploads', express.static('uploads'));

Dengan ini, gambar dapat diakses via http://localhost:3000/uploads/nama-file.jpg.

Menguji API dengan Postman

Pastikan server berjalan (node app.js). Kita akan menguji semua endpoint.

1. GET Semua Destinasi

  • Method: GET
  • URL: http://localhost:3000/api/destinations
  • Klik Send. Respons awal mungkin array kosong.

2. POST Tambah Destinasi (dengan gambar)

  • Method: POST
  • URL: http://localhost:3000/api/destinations
  • Body: pilih form-data
  • Isi key-value:
    • category_id (text): 1 (pastikan ID kategori ada)
    • name (text): Pantai Kuta
    • slug (text): pantai-kuta
    • location (text): Bali
    • description (text): Pantai terkenal dengan pasir putih.
    • facilities (text): Parkir, Toilet, Warung
    • cover_image (file): pilih file gambar dari komputer
  • Klik Send. Respons sukses akan mengembalikan data destinasi yang baru dibuat dengan ID.

3. GET Satu Destinasi

  • Method: GET
  • URL: http://localhost:3000/api/destinations/1 (ganti dengan ID yang baru)
  • Klik Send. Pastikan data dan relasi kategori muncul.

4. PUT Update Destinasi (dengan gambar baru)

  • Method: PUT
  • URL: http://localhost:3000/api/destinations/1
  • Body: form-data
  • Ubah beberapa field, misal name menjadi "Pantai Kuta Legian", dan upload gambar baru (atau tidak perlu upload).
  • Klik Send. Respons akan mengembalikan data terbaru. Cek folder uploads, gambar lama seharusnya sudah terhapus.

5. DELETE Hapus Destinasi

  • Method: DELETE
  • URL: http://localhost:3000/api/destinations/1
  • Klik Send. Respons: { "message": "Destinasi berhasil dihapus" }
  • Cek folder uploads, gambar juga harus ikut terhapus.
💡 Tips: Saat menguji dengan Postman, pastikan untuk mengatur header Content-Type tidak perlu diisi manual karena form-data akan mengatur sendiri.

Mengakses Gambar

Setelah upload berhasil, gambar dapat diakses melalui URL: http://localhost:3000/uploads/nama-file.jpg. Kamu bisa membukanya di browser untuk memastikan.

Penanganan Error

  • 400 Bad Request: Jika field wajib tidak diisi, slug duplikat, atau file bukan gambar.
  • 404 Not Found: Jika ID destinasi tidak ditemukan.
  • 500 Internal Server Error: Jika terjadi kesalahan server (koneksi db, dll).
  • Pada update dan delete, kita juga menghapus file fisik agar tidak menumpuk.

Langkah Selanjutnya

Di Studi Kasus #6, kita akan membuat API untuk Penginapan (Accommodations) dengan pola yang mirip, serta mulai memikirkan bagaimana menangani galeri foto (multiple images).

Pastikan semua endpoint destinasi berfungsi dengan baik. Jika ada error, periksa kembali kode, terutama bagian query dan path file.

Sampai jumpa di tutorial berikutnya! 👋😊


Ditulis dengan ❤️ untuk para pengembang yang ingin membangun aplikasi wisata.

Lebih baru Lebih lama

نموذج الاتصال