API Versioning & Rate Limiting - Perwira Learning Center




1. LATAR BELAKANG

    Setelah mempelajari file upload dan storage management, API kita semakin lengkap fiturnya. Namun, API yang profesional harus bisa berkembang tanpa merusak aplikasi client yang sudah ada. Bayangkan jika kita mengubah struktur response endpoint /api/posts - semua mobile app yang menggunakan API kita akan error! Di sinilah API Versioning menjadi sangat penting.

Selain itu, API publik harus dilindungi dari penyalahgunaan. Bayangkan jika ada pengguna yang melakukan request 1000 kali per detik - server kita bisa down dan biaya membengkak. Di sinilah Rate Limiting berperan untuk membatasi jumlah request dalam periode waktu tertentu.

Pada hari keempat minggu ini, saya mempelajari dua konsep krusial untuk API production:

1. API Versioning

  • Mengelola perubahan API tanpa merusak client lama
  • Strategi versioning (URL, Header, Accept Header)
  • Backward compatibility

2. Rate Limiting

  • Melindungi server dari abuse
  • Membatasi request per user/IP
  • Custom rate limiter per endpoint
  • Response handling saat limit tercapai

Dengan kedua fitur ini, API kita siap digunakan oleh banyak client dengan skala besar.

 

2. ALAT DAN BAHAN

2.1 Perangkat Lunak

  • Laravel 11 - Project API dari hari sebelumnya
  • Redis/Memcached - Untuk rate limiting yang lebih baik (opsional)
  • Postman/Thunder Client - Untuk testing rate limiting
  • Visual Studio Code - Editor kode
  • Git Bash/Terminal - Untuk menjalankan perintah Artisan

2.2 Perangkat Keras

  • Laptop dengan spesifikasi standar
 

3. PEMBAHASAN

3.1 API Versioning - Konsep Dasar

API Versioning adalah praktik membuat beberapa versi API secara bersamaan sehingga client bisa memilih versi mana yang ingin mereka gunakan.

3.1.1 Mengapa Perlu Versioning?
text
🚫 TANPA VERSIONING:

V1 (Januari) : /api/users → { "id": 1, "name": "John", "email": "john@mail.com" }
V2 (Maret)   : /api/users → { "id": 1, "full_name": "John Doe", "email_address": "john@mail.com" }

Hasilnya: Semua app yang menggunakan API V1 akan error karena struktur response berubah!
text
✅ DENGAN VERSIONING:

V1 : /api/v1/users → { "id": 1, "name": "John", "email": "john@mail.com" }
V2 : /api/v2/users → { "id": 1, "full_name": "John Doe", "email_address": "john@mail.com" }

Hasilnya: App lama tetap pakai V1, app baru bisa pakai V2. Semua happy!
3.1.2 Strategi Versioning

Ada 3 strategi umum untuk API versioning:

StrategiCaraContohKelebihanKekurangan
URL PathVersi di path URL/api/v1/postsSederhana, jelasMemperpanjang URL
Query ParameterVersi di query string/api/posts?version=1URL tetap bersihMudah lupa
HeaderVersi di Accept headerAccept: application/vnd.api.v1+jsonPaling RESTfulLebih kompleks

3.2 Versioning dengan URL Path (Paling Umum)

3.2.1 Struktur Folder untuk Versioning
text
app/
├── Http/
│   ├── Controllers/
│   │   ├── V1/
│   │   │   ├── PostController.php
│   │   │   ├── UserController.php
│   │   │   └── AuthController.php
│   │   └── V2/
│   │       ├── PostController.php
│   │       ├── UserController.php
│   │       └── AuthController.php
│   ├── Requests/
│   │   ├── V1/
│   │   │   └── PostRequest.php
│   │   └── V2/
│   │       └── PostRequest.php
│   └── Resources/
│       ├── V1/
│       │   ├── PostResource.php
│       │   └── UserResource.php
│       └── V2/
│           ├── PostResource.php
│           └── UserResource.php
3.2.2 Membuat Controller dengan Namespace
bash
# Buat controller untuk V1
php artisan make:controller V1/PostController --api --model=Post
php artisan make:controller V1/UserController --api --model=User

# Buat controller untuk V2
php artisan make:controller V2/PostController --api --model=Post
php artisan make:controller V2/UserController --api --model=User
3.2.3 Routing dengan Prefix Versi
php
<?php
// File: routes/api.php

use Illuminate\Support\Facades\Route;

// ===== API VERSION 1 =====
Route::prefix('v1')->group(function () {
    
    // Public endpoints V1
    Route::get('/posts', [App\Http\Controllers\V1\PostController::class, 'index']);
    Route::get('/posts/{post}', [App\Http\Controllers\V1\PostController::class, 'show']);
    
    // Auth endpoints V1
    Route::prefix('auth')->group(function () {
        Route::post('/login', [App\Http\Controllers\V1\AuthController::class, 'login']);
        Route::post('/register', [App\Http\Controllers\V1\AuthController::class, 'register']);
    });
    
    // Protected endpoints V1
    Route::middleware('auth:sanctum')->group(function () {
        Route::apiResource('posts', App\Http\Controllers\V1\PostController::class)
            ->except(['index', 'show']);
        Route::get('/user', [App\Http\Controllers\V1\UserController::class, 'profile']);
    });
});

// ===== API VERSION 2 =====
Route::prefix('v2')->group(function () {
    
    // Public endpoints V2 (dengan fitur baru)
    Route::get('/posts', [App\Http\Controllers\V2\PostController::class, 'index']);
    Route::get('/posts/{post}', [App\Http\Controllers\V2\PostController::class, 'show']);
    Route::get('/posts/{post}/comments', [App\Http\Controllers\V2\PostController::class, 'comments']);
    
    // Auth endpoints V2 (dengan fitur refresh token)
    Route::prefix('auth')->group(function () {
        Route::post('/login', [App\Http\Controllers\V2\AuthController::class, 'login']);
        Route::post('/register', [App\Http\Controllers\V2\AuthController::class, 'register']);
        Route::post('/refresh', [App\Http\Controllers\V2\AuthController::class, 'refresh']);
    });
    
    // Protected endpoints V2
    Route::middleware('auth:sanctum')->group(function () {
        Route::apiResource('posts', App\Http\Controllers\V2\PostController::class)
            ->except(['index', 'show']);
        Route::get('/user', [App\Http\Controllers\V2\UserController::class, 'profile']);
        Route::put('/user/preferences', [App\Http\Controllers\V2\UserController::class, 'preferences']);
    });
});
3.2.4 Controller V1 - Versi Lama
php
<?php
// File: app/Http/Controllers/V1/PostController.php

namespace App\Http\Controllers\V1;

use App\Http\Controllers\Controller;
use App\Models\Post;
use App\Http\Resources\V1\PostResource;
use App\Http\Requests\V1\PostRequest;
use App\Traits\ApiResponseTrait;

class PostController extends Controller
{
    use ApiResponseTrait;
    
    /**
     * GET /api/v1/posts
     * Versi 1: Hanya data dasar
     */
    public function index()
    {
        $posts = Post::with('author:id,name')
                    ->latest()
                    ->paginate(15);
        
        return PostResource::collection($posts);
    }
    
    /**
     * GET /api/v1/posts/{post}
     * Versi 1: Detail sederhana
     */
    public function show(Post $post)
    {
        $post->load('author:id,name');
        
        return new PostResource($post);
    }
    
    /**
     * POST /api/v1/posts
     * Versi 1: Create dengan field minimal
     */
    public function store(PostRequest $request)
    {
        $post = Post::create([
            'title' => $request->title,
            'content' => $request->content,
            'category_id' => $request->category_id,
            'user_id' => auth()->id()
        ]);
        
        return new PostResource($post);
    }
}
3.2.5 Controller V2 - Versi Baru dengan Fitur Tambahan
php
<?php
// File: app/Http/Controllers/V2/PostController.php

namespace App\Http\Controllers\V2;

use App\Http\Controllers\Controller;
use App\Models\Post;
use App\Http\Resources\V2\PostResource;
use App\Http\Requests\V2\PostRequest;
use App\Traits\ApiResponseTrait;
use Illuminate\Http\Request;

class PostController extends Controller
{
    use ApiResponseTrait;
    
    /**
     * GET /api/v2/posts
     * Versi 2: Dengan fitur filtering, sorting, dan metadata lengkap
     */
    public function index(Request $request)
    {
        $query = Post::with(['author:id,name,email', 'category', 'tags'])
                    ->withCount(['comments', 'likes']);
        
        // Filter by category
        if ($request->has('category')) {
            $query->whereHas('category', fn($q) => $q->where('slug', $request->category));
        }
        
        // Filter by tags
        if ($request->has('tags')) {
            $tagIds = explode(',', $request->tags);
            $query->whereHas('tags', fn($q) => $q->whereIn('tags.id', $tagIds));
        }
        
        // Search
        if ($request->has('search')) {
            $query->where(fn($q) => $q->where('title', 'LIKE', "%{$request->search}%")
                                       ->orWhere('content', 'LIKE', "%{$request->search}%"));
        }
        
        // Sorting multi kolom
        if ($request->has('sort')) {
            foreach (explode(',', $request->sort) as $sort) {
                $direction = str_starts_with($sort, '-') ? 'desc' : 'asc';
                $field = ltrim($sort, '-');
                $query->orderBy($field, $direction);
            }
        }
        
        $posts = $query->paginate($request->get('per_page', 15));
        
        return PostResource::collection($posts);
    }
    
    /**
     * GET /api/v2/posts/{post}
     * Versi 2: Detail lengkap dengan semua relasi
     */
    public function show(Post $post)
    {
        $post->load([
            'author:id,name,email,role',
            'category',
            'tags',
            'comments' => fn($q) => $q->latest()->limit(10),
            'comments.user:id,name'
        ])->loadCount(['comments', 'likes']);
        
        // Increment views
        $post->increment('views');
        
        return new PostResource($post);
    }
    
    /**
     * GET /api/v2/posts/{post}/comments
     * Fitur baru di V2: Ambil semua komentar post
     */
    public function comments(Post $post, Request $request)
    {
        $comments = $post->comments()
                        ->with('user:id,name')
                        ->latest()
                        ->paginate($request->get('per_page', 20));
        
        return response()->json([
            'data' => $comments,
            'post_title' => $post->title
        ]);
    }
    
    /**
     * POST /api/v2/posts
     * Versi 2: Create dengan field lengkap + tags
     */
    public function store(PostRequest $request)
    {
        $post = Post::create([
            'title' => $request->title,
            'content' => $request->content,
            'category_id' => $request->category_id,
            'user_id' => auth()->id(),
            'meta_description' => $request->meta_description,
            'featured_image' => $request->featured_image,
            'is_published' => $request->is_published ?? false,
            'published_at' => $request->is_published ? now() : null
        ]);
        
        // Attach tags jika ada
        if ($request->has('tags')) {
            $post->tags()->sync($request->tags);
        }
        
        $post->load(['category', 'tags']);
        
        return new PostResource($post);
    }
}
3.2.6 Resource V1 vs V2

Resource V1 - Sederhana:

php
<?php
// File: app/Http/Resources/V1/PostResource.php

namespace App\Http\Resources\V1;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class PostResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'title' => $this->title,
            'content' => $this->content,
            'author' => [
                'id' => $this->author->id,
                'name' => $this->author->name
            ],
            'created_at' => $this->created_at->format('Y-m-d')
        ];
    }
}

Resource V2 - Lengkap:

php
<?php
// File: app/Http/Resources/V2/PostResource.php

namespace App\Http\Resources\V2;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class PostResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'title' => $this->title,
            'slug' => $this->slug,
            'excerpt' => substr($this->content, 0, 150) . '...',
            'content' => $this->content,
            'featured_image' => $this->featured_image ? asset('storage/' . $this->featured_image) : null,
            'views' => $this->views,
            'meta_description' => $this->meta_description,
            
            'author' => [
                'id' => $this->author->id,
                'name' => $this->author->name,
                'avatar' => $this->author->profile?->avatar_url
            ],
            
            'category' => [
                'id' => $this->category->id,
                'name' => $this->category->name,
                'slug' => $this->category->slug
            ],
            
            'tags' => $this->whenLoaded('tags', function() {
                return $this->tags->map(fn($tag) => [
                    'id' => $tag->id,
                    'name' => $tag->name,
                    'slug' => $tag->slug
                ]);
            }),
            
            'stats' => [
                'comments_count' => $this->comments_count ?? 0,
                'likes_count' => $this->likes_count ?? 0
            ],
            
            'published_at' => $this->published_at?->format('Y-m-d H:i:s'),
            'created_at' => $this->created_at->format('Y-m-d H:i:s'),
            'created_ago' => $this->created_at->diffForHumans(),
            
            'links' => [
                'self' => route('api.v2.posts.show', $this->id),
                'comments' => route('api.v2.posts.comments', $this->id)
            ]
        ];
    }
}

3.3 Versioning dengan Accept Header (RESTful)

Untuk pendekatan yang lebih RESTful, kita bisa menggunakan Accept Header.

3.3.1 Middleware untuk Versioning via Header
bash
php artisan make:middleware ApiVersion
php
<?php
// File: app/Http/Middleware/ApiVersion.php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;

class ApiVersion
{
    /**
     * Handle an incoming request.
     */
    public function handle(Request $request, Closure $next, $version = 'v1')
    {
        // Cek Accept header
        $acceptHeader = $request->header('Accept');
        
        if ($acceptHeader) {
            // Format: application/vnd.api.v1+json
            preg_match('/application\/vnd\.api\.(v\d+)\+json/', $acceptHeader, $matches);
            
            if (isset($matches[1])) {
                $version = $matches[1];
            }
        }
        
        // Set versi di request untuk digunakan nanti
        $request->attributes->set('api_version', $version);
        
        // Ubah namespace controller berdasarkan versi
        $this->setControllerNamespace($version);
        
        return $next($request);
    }
    
    /**
     * Set controller namespace based on version
     */
    protected function setControllerNamespace($version)
    {
        $version = strtoupper($version);
        app('config')->set('api.controller_namespace', "App\\Http\\Controllers\\{$version}");
    }
}
3.3.2 Daftarkan Middleware
php
<?php
// File: app/Http/Kernel.php

protected $routeMiddleware = [
    // ...
    'api.version' => \App\Http\Middleware\ApiVersion::class,
];
3.3.3 Routing dengan Middleware Version
php
<?php
// File: routes/api.php

use Illuminate\Support\Facades\Route;

Route::middleware(['api.version'])->group(function () {
    
    // Posts routes - akan mengarah ke controller sesuai versi
    Route::get('/posts', function (Request $request) {
        $version = $request->attributes->get('api_version', 'v1');
        $controller = app('config')->get('api.controller_namespace') . '\PostController';
        
        return app($controller)->index($request);
    });
    
    Route::get('/posts/{post}', function (Request $request, $post) {
        $controller = app('config')->get('api.controller_namespace') . '\PostController';
        return app($controller)->show($request, $post);
    });
});
3.3.4 Penggunaan di Client
http
# Request dengan versi 1
GET /api/posts
Accept: application/vnd.api.v1+json

# Request dengan versi 2
GET /api/posts
Accept: application/vnd.api.v2+json

3.4 Rate Limiting - Konsep Dasar

Rate limiting membatasi jumlah request yang bisa dilakukan client dalam periode waktu tertentu.

3.4.1 Mengapa Perlu Rate Limiting?
  1. Mencegah DDoS - Serangan yang membanjiri server
  2. Fair Usage - Semua client mendapat jatah yang adil
  3. Proteksi dari Bug - Mencegah infinite loop di client
  4. Monetisasi - Membuat tier berbayar dengan limit lebih tinggi
  5. Stabilitas Server - Menjaga performa untuk semua pengguna
3.4.2 Rate Limiting Bawaan Laravel

Laravel sudah menyediakan middleware throttle untuk rate limiting.

php
// Format: throttle:max_attempts,decay_minutes
Route::middleware('throttle:60,1')->group(function () {
    Route::get('/posts', [PostController::class, 'index']);
});

3.5 Implementasi Rate Limiting

3.5.1 Rate Limiting Global untuk API
php
<?php
// File: app/Http/Kernel.php

protected $middlewareGroups = [
    'api' => [
        'throttle:60,1', // 60 request per menit untuk semua endpoint API
        \Illuminate\Routing\Middleware\SubstituteBindings::class,
    ],
];
3.5.2 Rate Limiting Berbeda per Endpoint
php
<?php
// File: routes/api.php

use Illuminate\Support\Facades\Route;

// Endpoint publik - limit rendah
Route::middleware('throttle:30,1')->group(function () {
    Route::get('/posts', [PostController::class, 'index']);
    Route::get('/posts/{post}', [PostController::class, 'show']);
});

// Endpoint yang berat - limit sangat rendah
Route::middleware('throttle:10,1')->group(function () {
    Route::get('/posts/{post}/export', [PostController::class, 'export']);
    Route::get('/reports/generate', [ReportController::class, 'generate']);
});

// Endpoint authenticated - limit lebih tinggi
Route::middleware(['auth:sanctum', 'throttle:100,1'])->group(function () {
    Route::apiResource('posts', PostController::class)->except(['index', 'show']);
    Route::post('/posts/{post}/comments', [CommentController::class, 'store']);
});

// Endpoint khusus admin - limit sangat tinggi
Route::middleware(['auth:sanctum', 'role:admin', 'throttle:500,1'])->group(function () {
    Route::get('/admin/stats', [AdminController::class, 'stats']);
    Route::post('/admin/bulk-import', [AdminController::class, 'import']);
});
3.5.3 Rate Limiting Berdasarkan User/IP
php
// throttle:60,1|api -> 60 untuk authenticated, 30 untuk guest
Route::middleware('throttle:60,1|30,1')->group(function () {
    Route::get('/posts', [PostController::class, 'index']);
});
3.5.4 Custom Rate Limiter dengan Redis

Untuk kontrol lebih, kita bisa membuat rate limiter kustom.

php
<?php
// File: app/Providers/AppServiceProvider.php

use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;

public function boot()
{
    // Rate limiter untuk API umum
    RateLimiter::for('api', function (Request $request) {
        return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
    });
    
    // Rate limiter untuk upload
    RateLimiter::for('uploads', function (Request $request) {
        return $request->user()
            ? Limit::perMinute(10)->by($request->user()->id)
            : Limit::perMinute(3)->by($request->ip());
    });
    
    // Rate limiter untuk login (mencegah brute force)
    RateLimiter::for('login', function (Request $request) {
        return [
            Limit::perMinute(5)->by($request->input('email') ?: $request->ip()),
            Limit::perMinute(100)->by($request->ip()),
        ];
    });
    
    // Rate limiter berdasarkan role
    RateLimiter::for('posts', function (Request $request) {
        if ($request->user()?->role === 'admin') {
            return Limit::perMinute(200)->by($request->user()->id);
        }
        
        if ($request->user()) {
            return Limit::perMinute(50)->by($request->user()->id);
        }
        
        return Limit::perMinute(10)->by($request->ip());
    });
    
    // Rate limiter dengan dynamic response
    RateLimiter::for('critical', function (Request $request) {
        return Limit::perMinute(5)->by($request->user()?->id ?: $request->ip())
                    ->response(function (Request $request, array $headers) {
                        return response()->json([
                            'success' => false,
                            'message' => 'Terlalu banyak percobaan. Silakan tunggu ' . 
                                        $headers['Retry-After'] . ' detik.',
                            'retry_after' => $headers['Retry-After']
                        ], 429);
                    });
    });
}
3.5.5 Menggunakan Custom Rate Limiter di Routes
php
<?php
// File: routes/api.php

// Menggunakan rate limiter 'api'
Route::middleware(['throttle:api'])->group(function () {
    Route::get('/posts', [PostController::class, 'index']);
});

// Menggunakan rate limiter 'uploads'
Route::middleware(['auth:sanctum', 'throttle:uploads'])->group(function () {
    Route::post('/posts/{post}/images', [PostController::class, 'uploadImages']);
});

// Menggunakan rate limiter 'login' untuk endpoint login
Route::post('/auth/login', [AuthController::class, 'login'])
    ->middleware('throttle:login');

// Menggunakan rate limiter 'posts' yang dinamis
Route::middleware(['throttle:posts'])->group(function () {
    Route::post('/posts', [PostController::class, 'store']);
    Route::put('/posts/{post}', [PostController::class, 'update']);
    Route::delete('/posts/{post}', [PostController::class, 'destroy']);
});

3.6 Advanced Rate Limiting

3.6.1 Rate Limiting per Endpoint dengan Dynamic Limits
php
<?php
// File: app/Providers/AppServiceProvider.php

use Illuminate\Support\Facades\RateLimiter;

RateLimiter::for('dynamic', function (Request $request) {
    $user = $request->user();
    
    if (!$user) {
        return Limit::perMinute(10)->by($request->ip());
    }
    
    // Different limits based on user subscription
    switch ($user->subscription_tier) {
        case 'premium':
            return Limit::perMinute(500)->by($user->id);
        case 'basic':
            return Limit::perMinute(100)->by($user->id);
        default:
            return Limit::perMinute(30)->by($user->id);
    }
});
3.6.2 Rate Limiting dengan Burst Capacity
php
use Illuminate\Cache\RateLimiting\Limit;

RateLimiter::for('burst', function (Request $request) {
    return [
        // Burst: 10 request per 10 detik
        Limit::perMinutes(10, 10)->by($request->ip()),
        // Sustained: 100 request per jam
        Limit::perHour(100)->by($request->ip()),
    ];
});
3.6.3 Rate Limiting untuk API Keys
php
RateLimiter::for('api_key', function (Request $request) {
    $apiKey = $request->header('X-API-Key');
    
    if (!$apiKey) {
        return Limit::perMinute(5)->by($request->ip());
    }
    
    // Cari API key di database
    $key = ApiKey::where('key', $apiKey)->first();
    
    if ($key && $key->is_active) {
        return Limit::perMinute($key->rate_limit)->by($apiKey);
    }
    
    return Limit::perMinute(5)->by($request->ip());
});

3.7 Response Handling Saat Rate Limit Tercapai

3.7.1 Response Default Laravel

Saat rate limit tercapai, Laravel mengembalikan response:

json
{
    "message": "Too Many Attempts."
}

Status code: 429 Too Many Requests

Headers yang dikirim:

text
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
Retry-After: 58
3.7.2 Custom Response untuk Rate Limit
php
<?php
// File: app/Exceptions/Handler.php

use Illuminate\Http\Exceptions\ThrottleRequestsException;

public function render($request, Throwable $exception)
{
    if ($exception instanceof ThrottleRequestsException) {
        return response()->json([
            'success' => false,
            'message' => 'Terlalu banyak permintaan. Silakan tunggu beberapa saat.',
            'limit' => $exception->getHeaders()['X-RateLimit-Limit'] ?? null,
            'remaining' => $exception->getHeaders()['X-RateLimit-Remaining'] ?? null,
            'retry_after' => $exception->getHeaders()['Retry-After'] ?? null
        ], 429);
    }
    
    return parent::render($request, $exception);
}
3.7.3 Memberi Informasi Rate Limit di Response
php
<?php
// File: app/Http/Middleware/AddRateLimitHeaders.php

namespace App\Http\Middleware;

use Closure;

class AddRateLimitHeaders
{
    public function handle($request, Closure $next)
    {
        $response = $next($request);
        
        if (method_exists($response, 'header') && $request->route()?->middleware()) {
            // Hitung sisa limit (ini contoh sederhana)
            $executed = RateLimiter::attempts('api');
            $limit = 60;
            
            $response->header('X-RateLimit-Limit', $limit);
            $response->header('X-RateLimit-Remaining', max(0, $limit - $executed));
        }
        
        return $response;
    }
}

3.8 Testing Rate Limiting

3.8.1 Testing dengan PHPUnit
php
<?php
// File: tests/Feature/RateLimitTest.php

namespace Tests\Feature;

use Tests\TestCase;
use App\Models\User;
use Illuminate\Support\Facades\RateLimiter;

class RateLimitTest extends TestCase
{
    public function test_rate_limit_for_guests()
    {
        // Lakukan 31 request (limit 30 per menit)
        for ($i = 0; $i < 31; $i++) {
            $response = $this->getJson('/api/posts');
            
            if ($i < 30) {
                $response->assertStatus(200);
            } else {
                $response->assertStatus(429);
                $response->assertJson([
                    'message' => 'Too Many Attempts.'
                ]);
            }
        }
    }
    
    public function test_rate_limit_for_authenticated_users()
    {
        $user = User::factory()->create();
        $token = $user->createToken('test')->plainTextToken;
        
        // Lakukan 101 request (limit 100 per menit untuk auth)
        for ($i = 0; $i < 101; $i++) {
            $response = $this->withHeaders([
                    'Authorization' => 'Bearer ' . $token
                ])
                ->getJson('/api/posts');
            
            if ($i < 100) {
                $response->assertStatus(200);
            } else {
                $response->assertStatus(429);
            }
        }
    }
}
3.8.2 Testing dengan Postman

Test Script di Postman:

javascript
// Tests tab
pm.test("Rate limit headers exist", function () {
    pm.expect(pm.response.headers.get('X-RateLimit-Limit')).to.exist;
    pm.expect(pm.response.headers.get('X-RateLimit-Remaining')).to.exist;
});

pm.test("Status code is 200 or 429", function () {
    pm.expect(pm.response.code).to.be.oneOf([200, 429]);
});

// Jika kena rate limit
if (pm.response.code === 429) {
    let retryAfter = pm.response.headers.get('Retry-After');
    console.log('Rate limited. Retry after ' + retryAfter + ' seconds');
    
    // Set delay untuk request berikutnya
    setTimeout(function() {
        console.log('Sending request again...');
        // Logic untuk repeat request
    }, retryAfter * 1000);
}
3.8.3 Testing dengan Command Line
bash
# Test rate limit dengan curl loop
for i in {1..100}; do
    curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8000/api/posts
done

# Dengan header
for i in {1..100}; do
    curl -s -o /dev/null -w "%{http_code}\n" \
         -H "Authorization: Bearer your-token" \
         http://localhost:8000/api/posts
done

3.9 Best Practices API Versioning & Rate Limiting

3.9.1 Versioning Best Practices
  1. Rencanakan dari awal - Meskipun V1 sederhana, siapkan struktur untuk V2
  2. Dokumentasi jelas - Beri tahu client versi mana yang akan di-deprecate
  3. Support minimal 2 versi - Beri waktu client migrasi (6-12 bulan)
  4. Semantic Versioning - Gunakan v1, v2, bukan v1.0.1 (untuk API)
  5. Backward compatibility - Jangan hapus field di versi yang sama
3.9.2 Rate Limiting Best Practices
  1. Beri informasi jelas - Sertakan header rate limit di response
  2. Limit berbeda untuk tiap endpoint - Login lebih ketat dari read-only
  3. Burst vs sustained - Izinkan burst pendek, batasi rata-rata
  4. Monitor dan adjust - Lihat pattern usage, sesuaikan limit
  5. Graceful degradation - Beri response yang informatif saat kena limit
3.9.3 Contoh Kebijakan Rate Limiting Realistis
EndpointGuestUser BiasaPremiumAdmin
GET /api/posts30/min60/min120/min300/min
POST /api/posts-10/min30/min100/min
POST /api/auth/login5/min---
POST /api/upload-3/min10/min50/min
GET /api/admin/*---200/min

3.10 Kendala dan Solusi

KendalaSolusi
Lupa menambahkan versioning di awalRefactor routing, buat prefix v1 untuk semua endpoint existing
Cache rate limit tidak bekerja di load balancerGunakan Redis sebagai central cache, bukan file
Rate limit terlalu ketatMonitoring, analisis usage pattern, adjust limit
User mengeluh kena limit padahal tidak wajarCek kemungkinan bug di client (infinite loop), beri penjelasan
Versioning bikin kode duplicateGunakan traits untuk shared logic, inheritance untuk controller
Testing rate limit lambatGunakan Cache::shouldReceive() untuk mock di test
Lupa deprecate versi lamaBuat schedule deprecation, kirim warning ke developer
 

4. KESIMPULAN

API Versioning dan Rate Limiting adalah dua fitur yang membuat API kita siap untuk skala produksi dengan banyak client.

  1. API Versioning 
    • URL Path Versioning: Sederhana, jelas, paling umum digunakan
    • Header Versioning: RESTful, URL bersih, lebih kompleks
    • Struktur Folder: Pisahkan Controller, Request, Resource per versi
    • Backward Compatibility: Client lama tetap bisa pakai versi lama
  2. Rate Limiting 
    • Global Limiting: Proteksi dasar untuk semua endpoint
    • Per-Endpoint Limiting: Limit berbeda untuk tiap fungsionalitas
    • User-Based Limiting: Authenticated user dapat limit lebih tinggi
    • Custom Rate Limiter: Redis, dynamic based on role/tier
    • Response Headers: Informasi limit untuk client

Manfaat yang Didapat: 

  • API bisa berkembang tanpa merusak client existing 
  • Server terlindungi dari abuse dan DDoS 
  • Fair usage untuk semua pengguna 
  • Bisa monetisasi dengan tier berbeda 
  • Client mendapat informasi jelas tentang limit mereka

Dengan kedua fitur ini, API kita sekarang benar-benar production-ready. Pada hari kelima, kita akan mempelajari Testing REST API untuk memastikan semua fitur bekerja dengan baik dan tidak ada regression.

 

5. DAFTAR PUSTAKA

Posting Komentar

0 Komentar