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:
diskStoragemenentukan di mana file disimpan dan bagaimana penamaannya.- Nama file dibuat unik agar tidak tertimpa.
fileFiltermemastikan hanya file gambar yang diupload.limitsmembatasi 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
getAllDestinationsdangetDestinationById, 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
fsuntuk 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 Kutaslug(text): pantai-kutalocation(text): Balidescription(text): Pantai terkenal dengan pasir putih.facilities(text): Parkir, Toilet, Warungcover_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
namemenjadi "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.
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.