Versioning dan konsistensi

Bentuk bungkus respons yang seragam dan format pesan error API.

Daftar isi

Respons API adalah kontrak antara aplikasi dan seluruh klien yang menggunakannya, sehingga bentuknya perlu diseragamkan sejak awal. Topik ini membahas tiga penerapan konsistensi pada aplikasi catatan, yaitu menetapkan bungkus respons untuk keadaan sukses dan gagal, memusatkan pembentukannya pada satu kelas, dan memberi awalan versi (versioning) pada seluruh endpoint. Format pesan error ikut diselaraskan agar klien cukup menulis satu penangan error untuk seluruh API. Setiap perubahan diuji dengan permintaan HTTP nyata sehingga bentuk respons sebelum dan sesudah konvensi dapat dibandingkan.

Bentuk respons yang belum seragam#

Rute API yang sudah dibangun untuk aplikasi catatan menghasilkan beberapa bentuk respons sekaligus. Daftar catatan dikirim dalam kunci data beserta kunci jumlah, penyimpanan catatan dijawab dengan pesan dan judul, sedangkan error validasi berbentuk message dan errors dari framework. Respons rute yang terlindungi token menambahkan bentuk lain berupa {"message":"Unauthenticated."}. Tabel berikut merangkum keadaannya.

PermintaanBentuk respons saat ini
GET /api/catatan{"data": [...], "jumlah": 3}
POST /api/catatan berhasil{"pesan": "Catatan tersimpan.", "judul": "Rangkuman materi"}
POST /api/catatan gagal validasi{"message": "The judul field is required.", "errors": {...}}
Token tidak sah{"message": "Unauthenticated."}

Bentuk campuran seperti ini memberatkan klien. Kode yang menguraikan respons pada frontend harus mengenali beberapa nama kunci untuk maksud yang serupa, misalnya pesan pada keberhasilan tetapi message pada kegagalan, sehingga penangan responsnya tidak dapat ditulis sekali untuk semua endpoint. Kunci judul pada respons penyimpanan juga hanya mengulang masukan tanpa menambah informasi. Apabila satu kunci diganti di sisi server, kode klien yang membacanya ikut rusak, dan tidak ada penanda versi yang memperingatkannya sebelumnya.

Konsistensi diselesaikan dengan dua keputusan, yaitu konvensi bentuk yang berlaku untuk semua endpoint dan pembungkusan versi agar perubahan berbahaya dapat dikelola. Keduanya diterapkan pada bagian berikut.

Konvensi bungkus respons#

Konvensi yang diterapkan menyatukan seluruh respons API pada tiga kunci, yaitu data, pesan, dan errors. Kunci data memuat payload, yaitu isi yang diminta klien, sedangkan pesan memuat teks yang dapat dibaca manusia dan errors memuat rincian error per kolom untuk kegagalan validasi. Kunci yang tidak berisi apa pun tidak dikirim sama sekali, sehingga respons hanya memuat kunci yang relevan. Status HTTP tetap menyatakan keadaan secara terpisah dari isi respons.

KeadaanStatusKunci yang dikirim
Sukses dengan payload200 atau 201data, pesan (opsional)
Gagal validasi422pesan, errors
Gagal lainnya401, 403, atau 404pesan

Pembentukan bungkus ini dipusatkan pada satu kelas bernama ResponsApi agar tidak ada rute yang menyusunnya sendiri-sendiri. Metode sukses menerima payload, pesan opsional, dan status, sedangkan metode gagal menerima pesan, rincian error opsional, dan status. Panggilan array_filter menghilangkan kunci bernilai null sebelum respons dikirim.

bash
# menuliskan kelas ResponsApi pada proyek uji
cd ~/aplikasi-catatan
cat > app/Http/ResponsApi.php <<'PHP'
<?php

namespace App\Http;

use Illuminate\Http\JsonResponse;

class ResponsApi
{
    public static function sukses(mixed $data = null, ?string $pesan = null, int $status = 200): JsonResponse
    {
        return response()->json(
            array_filter(['data' => $data, 'pesan' => $pesan], fn ($nilai) => $nilai !== null),
            $status
        );
    }

    public static function gagal(string $pesan, array $errors = [], int $status = 422): JsonResponse
    {
        return response()->json(
            array_filter(['pesan' => $pesan, 'errors' => $errors ?: null], fn ($nilai) => $nilai !== null),
            $status
        );
    }
}
PHP

Kelas ini memanfaatkan response()->json() sehingga header Content-Type: application/json terpasang otomatis. Karena kedua metodenya statis, rute cukup memanggil ResponsApi::sukses() tanpa membuat objek baru terlebih dahulu. Kedua bentuk keadaan respons digambarkan sebagai berikut.

Respons sukses"data"payload respons"pesan"opsional, teks untuk manusiastatus 200 atau 201Respons gagal"pesan"ringkasan kegagalan"errors"opsional, rincian per kolomstatus 422, 404, 401, atau 403Status HTTP menyatakan keadaan, sedangkan kunci bungkus menyatakan isinya.
Bentuk bungkus respons yang seragam

Penerapan pada rute dan awalan versi#

Seluruh endpoint API merupakan kontrak yang diandalkan oleh aplikasi di luar server. Perubahan yang mengganti nama kunci, menghapus atribut, atau mengubah makna status disebut perubahan merusak (breaking change) karena respons lama tidak lagi dipahami klien yang sudah berjalan. Cara paling umum menyatakan versi adalah awalan pada URL, misalnya /api/v1/catatan, karena terbaca pada log server dan dirutekan tanpa konfigurasi tambahan. Alternatifnya adalah menempatkan versi pada header Accept, tetapi pendekatan tersebut menuntut pembacaan header pada setiap rute sehingga jarang dipilih untuk aplikasi berukuran kecil.

Pada aplikasi catatan, seluruh rute API dibungkus Route::prefix('v1') sehingga URI aslinya tidak berubah dan hanya awalannya yang ditambahkan. Grup ini mencakup tiga rute catatan beserta rute login dan pengguna. Rute GET /catatan/{id} sekaligus disesuaikan dengan menggunakan find() sebagai ganti findOrFail(), sehingga id yang tidak dikenal dijawab dengan respons 404 yang seragam melalui ResponsApi::gagal(), bukan sebagai error bawaan framework.

bash
# menimpa routes/api.php dengan rute versi v1 dan bungkus respons seragam
cd ~/aplikasi-catatan
cat > routes/api.php <<'PHP'
<?php

use App\Http\Resources\NoteCollection;
use App\Http\Resources\NoteResource;
use App\Http\ResponsApi;
use App\Models\Category;
use App\Models\Note;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Route;
use Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful;

Route::prefix('v1')->group(function () {
    Route::get('/catatan', function () {
        return new NoteCollection(
            Note::with('category')
                ->where('category_id', Category::where('nama', 'Arsip')->value('id'))
                ->orderBy('judul')
                ->get()
        );
    });

    Route::get('/catatan/{id}', function (string $id) {
        $note = Note::with('category')->find($id);

        if ($note === null) {
            return ResponsApi::gagal('Catatan tidak ditemukan.', [], 404);
        }

        return new NoteResource($note);
    });

    Route::post('/catatan', function (Request $request) {
        $data = $request->validate([
            'judul' => ['required', 'string', 'max:100'],
            'isi' => ['required', 'string'],
            'kategori' => ['required', 'string'],
        ]);

        $kategori = Category::firstOrCreate(['nama' => $data['kategori']]);
        $note = Note::firstOrCreate(
            ['judul' => $data['judul']],
            ['isi' => $data['isi'], 'category_id' => $kategori->id]
        );

        return ResponsApi::sukses(new NoteResource($note), 'Catatan tersimpan.', 201);
    });

    Route::middleware(EnsureFrontendRequestsAreStateful::class)->group(function () {
        Route::post('/login', function (Request $request) {
            $data = $request->validate([
                'email' => ['required', 'email'],
                'password' => ['required', 'string'],
            ]);

            if (! Auth::attempt($data)) {
                return response()->json(['pesan' => 'Kredensial tidak cocok.'], 401);
            }

            return response()->json([
                'pesan' => 'Login berhasil.',
                'nama' => $request->user()->name,
            ]);
        });

        Route::get('/pengguna', function (Request $request) {
            if (! $request->user()->tokenCan('profil:lihat')) {
                return response()->json([
                    'pesan' => 'Token tidak memiliki izin profil:lihat.',
                ], 403);
            }

            return response()->json($request->user()->only(['name', 'email']));
        })->middleware('auth:sanctum');
    });
});
PHP

Rute penyimpanan menjadi contoh konvensi pada keadaan sukses. Setelah catatan tersimpan, responsnya memuat data berisi representasi catatan melalui NoteResource disusul pesan yang menyatakan keadaannya, dengan status 201. Pendaftaran awalan versi dapat diperiksa dengan route:list.

bash
# memeriksa rute api yang membawa awalan versi
cd ~/aplikasi-catatan
php artisan route:list --path=api/v1
text Keluaran
  GET|HEAD   api/v1/catatan ................................ routes/api.php:14
  POST       api/v1/catatan ................................ routes/api.php:33
  GET|HEAD   api/v1/catatan/{id} ........................... routes/api.php:23
  POST       api/v1/login .................................. routes/api.php:50
  GET|HEAD   api/v1/pengguna ............................... routes/api.php:66

                                                            Showing [5] routes

Kelima rute tampil dengan awalan api/v1, sedangkan berkas rute tetap menuliskan URI tanpa awalan. Kolom aksi menunjukkan berkas dan baris asal setiap rute, sehingga pemetaan versi tetap mudah dilacak saat daftar rutenya bertambah.

Format pesan error yang seragam#

Error validasi tidak dibentuk oleh rute, melainkan oleh exception handler yang menangkap ValidationException sebelum rute sempat menjawab. Penyelarasannya dilakukan pada bootstrap/app.php melalui metode withExceptions, yang menerima fungsi callback untuk setiap jenis error. Callback berikut memeriksa apakah permintaan berasal dari rute api/v1/*, lalu mengembalikan respons melalui ResponsApi::gagal() beserta rincian kolom dari $e->errors(). Cakupan ini dipersempit ke rute yang sudah diberi versi, sehingga rute lain di luar v1, apabila kelak ada, tetap menggunakan format bawaan framework. Rute web tidak tersentuh, sehingga formulir Blade tetap mengalihkan kembali dengan pesan error pada session.

bash
# menambahkan render callback ValidationException pada bootstrap/app.php
cd ~/aplikasi-catatan
cat > bootstrap/app.php <<'PHP'
<?php

use App\Http\ResponsApi;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        api: __DIR__.'/../routes/api.php',
        commands: __DIR__.'/../routes/console.php',
        health: '/up',
    )
    ->withMiddleware(function (Middleware $middleware): void {
        //
    })
    ->withExceptions(function (Exceptions $exceptions): void {
        $exceptions->shouldRenderJsonWhen(
            fn (Request $request) => $request->is('api/*') || $request->expectsJson(),
        );

        $exceptions->render(function (ValidationException $e, Request $request) {
            if ($request->is('api/v1/*')) {
                return ResponsApi::gagal('Data yang dikirim tidak valid.', $e->errors());
            }
        });
    })->create();
PHP

Perilaku lengkapnya diuji dengan skrip berikut yang menjalankan lima skenario pada rute versi v1, yaitu membaca daftar, membaca satu catatan, menyimpan dengan data lengkap, menyimpan tanpa judul, dan membaca id yang tidak ada. Data contoh disiapkan secara deterministik di awal skrip dengan id kategori dan catatan yang dipatok agar keluaran selalu sama.

php uji-konsistensi.php
<?php

use App\Models\Note;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;

$root = getcwd();
if (! is_file($root.'/bootstrap/app.php')) {
    $root = getenv('HOME').'/aplikasi-catatan';
}

require $root.'/vendor/autoload.php';

$app = require $root.'/bootstrap/app.php';
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();

// data contoh kategori Arsip disiapkan deterministik, id dipatok
DB::table('notes')->delete();
DB::statement("DELETE FROM sqlite_sequence WHERE name = 'notes'");
DB::table('categories')->delete();
DB::table('categories')->insert([
    'id' => 4, 'nama' => 'Arsip',
    'created_at' => '2026-01-05 08:00:00', 'updated_at' => '2026-01-05 08:00:00',
]);
$data = [
    [5, 'Daftar tugas harian', 'Membagi pekerjaan besar menjadi langkah yang selesai dalam satu hari.'],
    [6, 'Pengingat rapat', 'Menyiapkan poin pembahasan sebelum rapat dimulai.'],
    [7, 'Rencana mingguan', 'Menyusun prioritas pekerjaan untuk minggu berikutnya.'],
];
foreach ($data as [$id, $judul, $isi]) {
    DB::table('notes')->insert([
        'id' => $id, 'judul' => $judul, 'isi' => $isi, 'category_id' => 4,
        'created_at' => '2026-01-05 08:00:00', 'updated_at' => '2026-01-05 08:00:00',
    ]);
}

$kernel = $app->make(Illuminate\Contracts\Http\Kernel::class);

// skenario 1: daftar catatan melalui NoteCollection
$resp = $kernel->handle(Request::create('http://localhost/api/v1/catatan', 'GET', [], [], [], [
    'HTTP_ACCEPT' => 'application/json',
]));
echo '1. GET /api/v1/catatan: status '.$resp->getStatusCode().PHP_EOL;
echo $resp->getContent().PHP_EOL;

// skenario 2: satu catatan melalui NoteResource
$id = Note::where('judul', 'Rencana mingguan')->value('id');
$resp = $kernel->handle(Request::create('http://localhost/api/v1/catatan/'.$id, 'GET', [], [], [], [
    'HTTP_ACCEPT' => 'application/json',
]));
echo '2. GET /api/v1/catatan/{id}: status '.$resp->getStatusCode().PHP_EOL;
echo $resp->getContent().PHP_EOL;

// skenario 3: menyimpan catatan dengan bungkus respons seragam
$resp = $kernel->handle(Request::create('http://localhost/api/v1/catatan', 'POST', [], [], [], [
    'CONTENT_TYPE' => 'application/json',
    'HTTP_ACCEPT' => 'application/json',
], json_encode([
    'judul' => 'Rangkuman materi',
    'isi' => 'Poin penting dari materi yang dipelajari.',
    'kategori' => 'Belajar',
])));
echo '3. POST /api/v1/catatan: status '.$resp->getStatusCode().PHP_EOL;
echo $resp->getContent().PHP_EOL;

// skenario 4: validasi gagal dengan format error seragam
$resp = $kernel->handle(Request::create('http://localhost/api/v1/catatan', 'POST', [], [], [], [
    'CONTENT_TYPE' => 'application/json',
    'HTTP_ACCEPT' => 'application/json',
], json_encode([
    'isi' => 'Poin penting dari materi yang dipelajari.',
    'kategori' => 'Belajar',
])));
echo '4. POST tanpa judul: status '.$resp->getStatusCode().PHP_EOL;
echo $resp->getContent().PHP_EOL;

// skenario 5: id yang tidak ada dijawab 404 seragam
$resp = $kernel->handle(Request::create('http://localhost/api/v1/catatan/999999', 'GET', [], [], [], [
    'HTTP_ACCEPT' => 'application/json',
]));
echo '5. GET id yang tidak ada: status '.$resp->getStatusCode().PHP_EOL;
echo $resp->getContent().PHP_EOL;
text Keluaran
1. GET /api/v1/catatan: status 200
{"data":[{"id":5,"judul":"Daftar tugas harian","ringkasan":"Membagi pekerjaan besar menjadi langkah yang seles...","kategori":{"id":4,"nama":"Arsip"}},{"id":6,"judul":"Pengingat rapat","ringkasan":"Menyiapkan poin pembahasan sebelum rapat dimulai.","kategori":{"id":4,"nama":"Arsip"}},{"id":7,"judul":"Rencana mingguan","ringkasan":"Menyusun prioritas pekerjaan untuk minggu berikutn...","kategori":{"id":4,"nama":"Arsip"}}],"jumlah":3}
2. GET /api/v1/catatan/{id}: status 200
{"data":{"id":7,"judul":"Rencana mingguan","ringkasan":"Menyusun prioritas pekerjaan untuk minggu berikutn...","kategori":{"id":4,"nama":"Arsip"}}}
3. POST /api/v1/catatan: status 201
{"data":{"id":8,"judul":"Rangkuman materi","ringkasan":"Poin penting dari materi yang dipelajari."},"pesan":"Catatan tersimpan."}
4. POST tanpa judul: status 422
{"pesan":"Data yang dikirim tidak valid.","errors":{"judul":["The judul field is required."]}}
5. GET id yang tidak ada: status 404
{"pesan":"Catatan tidak ditemukan."}

Skenario pertama dan kedua menunjukkan bentuk resource yang dibangun pada topik API resource tetap berlaku, yaitu data untuk daftar beserta jumlah dan data untuk satu catatan. Skenario ketiga menghasilkan data berisi representasi catatan yang baru disimpan disusul pesan, menggantikan respons lama yang hanya mengulang judul masukan. Skenario keempat dijawab dengan 422 beserta pesan dan errors, sehingga kunci message bawaan framework tidak lagi muncul pada rute API. Skenario kelima menegaskan bahwa id yang tidak dikenal dijawab 404 dengan bentuk yang sama. Dengan kelima bentuk itu, penangan error klien cukup ditulis satu kali, yaitu membaca pesan dan errors setiap kali status menyatakan kegagalan.

Kesalahan umum#

Kesalahan pertama adalah menyusun bungkus respons langsung di setiap rute. Bentuk yang ditulis berulang-ulang cenderung berbeda detail, misalnya satu rute mengirim pesan dan rute lain mengirim message, sehingga ketidakseragaman kembali muncul. Perbaikannya adalah memusatkan pembentukan pada satu kelas seperti ResponsApi dan membiarkan rute hanya menentukan payload serta statusnya.

Kesalahan kedua adalah mengubah bentuk respons tanpa menaikkan versi. Menghapus kunci jumlah atau mengganti makna data pada /api/v1/catatan memutus klien yang sudah terpasang, dan kegagalan itu baru terlihat pada perangkat pengguna. Perbaikannya adalah menyiapkan awalan versi baru, misalnya v2, untuk perubahan merusak dan membiarkan v1 tetap melayani bentuk lamanya.

Kesalahan ketiga adalah mengirim respons gagal dengan status 200. Bungkus respons yang rapi tidak membantu klien yang memeriksa status HTTP terlebih dahulu, karena permintaan dianggap sukses padahal isinya error. Perbaikannya adalah memasangkan setiap pemanggilan ResponsApi::gagal() dengan status yang tepat, yaitu 422 untuk kegagalan validasi dan 404 untuk data yang tidak ditemukan.

Ringkasan#

  • Seluruh respons API aplikasi catatan menyatu pada tiga kunci, yaitu data untuk payload, pesan untuk teks yang dapat dibaca manusia, dan errors untuk rincian validasi, dengan status HTTP yang menyatakan keadaan.
  • Kelas ResponsApi memusatkan pembentukan respons, sedangkan callback render pada bootstrap/app.php menyelaraskan error validasi untuk seluruh rute api/v1/*.
  • Awalan /api/v1 diterapkan melalui Route::prefix('v1') sehingga perubahan merusak dapat ditempatkan pada versi baru tanpa mematikan klien lama.
  • Perintah route:list --path=api/v1 menampilkan seluruh rute api/v1 beserta awalan versi dan asal berkas setiap rute.

Referensi#