Files
gamelog/docs/superpowers/plans/2026-05-31-public-api-v1.md
T

999 lines
34 KiB
Markdown

# Public API v1 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a versioned public REST API (v1) with API key authentication, rate limiting, and a documentation page.
**Architecture:** API key auth via `Authorization: Bearer` header or `?api_key=` query param; all endpoints under `/api/v1/` prefix with 60 req/min throttle. API key management lives in the web app under `/settings/api-keys`. JSON controllers live in `App\Http\Controllers\Api\V1\`.
**Tech Stack:** Laravel 11, PHP 8.3, Blade + Tailwind + Alpine.js, no additional packages required.
---
## File Map
**Create:**
- `database/migrations/2026_05_31_000001_create_api_keys_table.php`
- `app/Models/ApiKey.php`
- `app/Http/Middleware/AuthenticateApiKey.php`
- `app/Http/Controllers/ApiKeyController.php`
- `app/Http/Controllers/Api/V1/GameApiController.php`
- `app/Http/Controllers/Api/V1/MetaApiController.php`
- `app/Http/Controllers/Api/V1/UserApiController.php`
- `resources/views/settings/api-keys.blade.php`
- `resources/views/api/docs.blade.php`
- `routes/api.php`
**Modify:**
- `bootstrap/app.php` — register `api.key` middleware alias, load `routes/api.php`, add JSON 404
- `app/Providers/AppServiceProvider.php` — add rate limiter
- `routes/web.php` — add API key management routes
- `resources/views/layouts/app.blade.php` — add "API" footer/nav link
---
### Task 1: Database migration for `api_keys`
**Files:**
- Create: `database/migrations/2026_05_31_000001_create_api_keys_table.php`
- [ ] **Step 1: Create migration file**
```php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('api_keys', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('name', 50);
$table->string('key', 64)->unique();
$table->timestamp('last_used_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('api_keys');
}
};
```
- [ ] **Step 2: Run migration**
```bash
php artisan migrate
```
Expected: `Migrating: 2026_05_31_000001_create_api_keys_table` then `Migrated`.
---
### Task 2: `ApiKey` model
**Files:**
- Create: `app/Models/ApiKey.php`
- [ ] **Step 1: Create model**
```php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
class ApiKey extends Model
{
protected $fillable = ['user_id', 'name', 'key'];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public static function generate(User $user, string $name): self
{
return self::create([
'user_id' => $user->id,
'name' => $name,
'key' => bin2hex(random_bytes(32)), // 64 hex chars
]);
}
}
```
---
### Task 3: `AuthenticateApiKey` middleware
**Files:**
- Create: `app/Http/Middleware/AuthenticateApiKey.php`
- [ ] **Step 1: Create middleware**
```php
<?php
namespace App\Http\Middleware;
use App\Models\ApiKey;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class AuthenticateApiKey
{
public function handle(Request $request, Closure $next): Response
{
$key = $request->bearerToken() ?? $request->get('api_key');
if (! $key) {
return response()->json(['error' => 'Invalid API key'], 401);
}
$apiKey = ApiKey::where('key', $key)->with('user')->first();
if (! $apiKey) {
return response()->json(['error' => 'Invalid API key'], 401);
}
auth()->login($apiKey->user);
$apiKey->update(['last_used_at' => now()]);
return $next($request);
}
}
```
---
### Task 4: Register middleware alias and load API routes in `bootstrap/app.php`
**Files:**
- Modify: `bootstrap/app.php`
- [ ] **Step 1: Update `bootstrap/app.php`**
Replace the entire file with:
```php
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
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 {
$middleware->alias([
'api.key' => \App\Http\Middleware\AuthenticateApiKey::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen(
fn (Request $request) => $request->is('api/*'),
);
})->create();
```
Note: Laravel 11's `withRouting(api:)` automatically prefixes `/api` and applies the `api` middleware group. Since we want `/api/v1/` paths, we'll use explicit Route::prefix in `routes/api.php` WITHOUT the automatic `/api` prefix — so we define routes in `web.php` or use a custom file. Actually, Laravel 11's `api:` param in `withRouting` wraps routes in `Route::middleware('api')->prefix('api')`. That would make our paths `/api/api/v1/...`. Instead, add the API routes file directly in `web.php` via `require`. See Task 8 for the approach.
**Revised Step 1: Update `bootstrap/app.php` (without `api:` param)**
```php
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->alias([
'api.key' => \App\Http\Middleware\AuthenticateApiKey::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen(
fn (Request $request) => $request->is('api/*'),
);
})->create();
```
---
### Task 5: Rate limiter in `AppServiceProvider`
**Files:**
- Modify: `app/Providers/AppServiceProvider.php`
- [ ] **Step 1: Add rate limiter to `boot()`**
```php
<?php
namespace App\Providers;
use App\Models\UserGame;
use App\Policies\UserGamePolicy;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
//
}
public function boot(): void
{
Gate::policy(UserGame::class, UserGamePolicy::class);
Gate::policy(\App\Models\Review::class, \App\Policies\ReviewPolicy::class);
Gate::policy(\App\Models\UserList::class, \App\Policies\ListPolicy::class);
Gate::policy(\App\Models\GameComment::class, \App\Policies\GameCommentPolicy::class);
RateLimiter::for('api', function (Request $request) {
$key = $request->bearerToken() ?? $request->get('api_key') ?? $request->ip();
return Limit::perMinute(60)->by($key)->response(function () {
return response()->json(['error' => 'Too many requests. Limit: 60/min.'], 429);
});
});
}
}
```
---
### Task 6: API routes file
**Files:**
- Create: `routes/api.php`
- Modify: `routes/web.php` (add `require` at bottom)
- [ ] **Step 1: Create `routes/api.php`**
```php
<?php
use App\Http\Controllers\Api\V1\GameApiController;
use App\Http\Controllers\Api\V1\MetaApiController;
use App\Http\Controllers\Api\V1\UserApiController;
use Illuminate\Support\Facades\Route;
Route::prefix('api/v1')->middleware(['api.key', 'throttle:api'])->group(function () {
// Games
Route::get('/games', [GameApiController::class, 'index']);
Route::get('/games/search', [GameApiController::class, 'search']);
Route::get('/games/top-rated', [GameApiController::class, 'topRated']);
Route::get('/games/new-releases', [GameApiController::class, 'newReleases']);
Route::get('/games/{game:slug}', [GameApiController::class, 'show']);
// Meta
Route::get('/genres', [MetaApiController::class, 'genres']);
Route::get('/platforms', [MetaApiController::class, 'platforms']);
// Users (public profiles)
Route::get('/users/{user:slug}', [UserApiController::class, 'show']);
Route::get('/users/{user:slug}/library', [UserApiController::class, 'library']);
});
```
Note: The spec uses `{user:username}` but the User model's route key is `slug` (renamed from username in migration `2026_05_30_000002`). Using `{user:slug}` is correct.
- [ ] **Step 2: Include `api.php` from `web.php`**
Add at the very bottom of `routes/web.php`:
```php
require __DIR__.'/api.php';
```
---
### Task 7: `ApiKeyController`
**Files:**
- Create: `app/Http/Controllers/ApiKeyController.php`
- [ ] **Step 1: Create controller**
```php
<?php
namespace App\Http\Controllers;
use App\Models\ApiKey;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
class ApiKeyController extends Controller
{
public function index()
{
$keys = auth()->user()->apiKeys()->latest()->get();
return view('settings.api-keys', compact('keys'));
}
public function store(Request $request)
{
$request->validate(['name' => 'required|string|max:50']);
$apiKey = ApiKey::generate(auth()->user(), $request->name);
return redirect()->route('settings.api-keys')->with('new_api_key', $apiKey->key);
}
public function destroy(ApiKey $apiKey)
{
Gate::authorize('delete', $apiKey);
$apiKey->delete();
return redirect()->route('settings.api-keys')->with('success', 'API key deleted.');
}
}
```
- [ ] **Step 2: Add `apiKeys` relation to `User` model**
In `app/Models/User.php`, add:
```php
public function apiKeys(): HasMany { return $this->hasMany(\App\Models\ApiKey::class); }
```
- [ ] **Step 3: Add policy for `ApiKey`**
Create `app/Policies/ApiKeyPolicy.php`:
```php
<?php
namespace App\Policies;
use App\Models\ApiKey;
use App\Models\User;
class ApiKeyPolicy
{
public function delete(User $user, ApiKey $apiKey): bool
{
return $user->id === $apiKey->user_id;
}
}
```
- [ ] **Step 4: Register policy in `AppServiceProvider`**
Add to `boot()` in `AppServiceProvider.php`:
```php
Gate::policy(\App\Models\ApiKey::class, \App\Policies\ApiKeyPolicy::class);
```
---
### Task 8: Web routes for API key management
**Files:**
- Modify: `routes/web.php`
- [ ] **Step 1: Add API key management routes inside `auth` middleware group**
Add before the `require __DIR__.'/auth.php'` line:
```php
// API Key Management
Route::middleware('auth')->group(function () {
Route::get('/settings/api-keys', [\App\Http\Controllers\ApiKeyController::class, 'index'])->name('settings.api-keys');
Route::post('/settings/api-keys', [\App\Http\Controllers\ApiKeyController::class, 'store'])->name('settings.api-keys.store');
Route::delete('/settings/api-keys/{apiKey}', [\App\Http\Controllers\ApiKeyController::class, 'destroy'])->name('settings.api-keys.destroy');
});
```
---
### Task 9: `settings/api-keys.blade.php` view
**Files:**
- Create: `resources/views/settings/api-keys.blade.php`
- [ ] **Step 1: Create view**
```blade
@extends('layouts.app')
@section('title', 'API Keys — LoreKeepers')
@section('content')
<div class="max-w-3xl mx-auto px-4 py-10">
<h1 class="text-2xl font-bold text-white mb-1">API Keys</h1>
<p class="text-slate-400 text-sm mb-8">Use API keys to access the <a href="{{ route('api.docs') }}" class="text-purple-400 hover:text-purple-300 underline">LoreKeepers API</a>. Keep them secret — treat them like passwords.</p>
{{-- New key revealed --}}
@if(session('new_api_key'))
<div class="mb-8 p-4 bg-purple-900/30 border border-purple-500/40 rounded-xl" x-data="{ copied: false }">
<div class="flex items-center gap-2 mb-2">
<svg class="w-4 h-4 text-yellow-400 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"/>
</svg>
<span class="text-yellow-300 text-sm font-semibold">This key will only be shown once. Copy it now.</span>
</div>
<div class="flex items-center gap-2 mt-3">
<code class="flex-1 bg-[#0f0f13] border border-white/10 rounded-lg px-4 py-2.5 text-sm text-green-300 font-mono break-all select-all">{{ session('new_api_key') }}</code>
<button @click="navigator.clipboard.writeText('{{ session('new_api_key') }}'); copied = true; setTimeout(() => copied = false, 2000)"
class="flex-shrink-0 px-3 py-2.5 bg-purple-600 hover:bg-purple-700 text-white text-sm rounded-lg transition-colors font-medium">
<span x-show="!copied">Copy</span>
<span x-show="copied">Copied!</span>
</button>
</div>
</div>
@endif
{{-- Existing keys --}}
<div class="bg-[#16161d] border border-white/5 rounded-xl mb-8 overflow-hidden">
<div class="px-5 py-4 border-b border-white/5">
<h2 class="text-sm font-semibold text-slate-200">Your API Keys</h2>
</div>
@forelse($keys as $key)
<div class="px-5 py-4 flex items-center justify-between gap-4 @if(!$loop->last) border-b border-white/5 @endif">
<div>
<div class="text-sm font-medium text-white">{{ $key->name }}</div>
<div class="text-xs text-slate-500 mt-0.5">
Created {{ $key->created_at->format('M j, Y') }}
@if($key->last_used_at)
· Last used {{ $key->last_used_at->diffForHumans() }}
@else
· Never used
@endif
</div>
</div>
<form method="POST" action="{{ route('settings.api-keys.destroy', $key) }}" onsubmit="return confirm('Delete this API key? This cannot be undone.')">
@csrf
@method('DELETE')
<button type="submit" class="text-xs px-3 py-1.5 bg-red-900/40 hover:bg-red-800/60 border border-red-700/40 text-red-400 hover:text-red-300 rounded-lg transition-colors">
Revoke
</button>
</form>
</div>
@empty
<div class="px-5 py-8 text-center text-slate-500 text-sm">
No API keys yet. Create one below.
</div>
@endforelse
</div>
{{-- Create new key --}}
<div class="bg-[#16161d] border border-white/5 rounded-xl overflow-hidden">
<div class="px-5 py-4 border-b border-white/5">
<h2 class="text-sm font-semibold text-slate-200">Create New Key</h2>
</div>
<form method="POST" action="{{ route('settings.api-keys.store') }}" class="px-5 py-4">
@csrf
<div class="flex items-end gap-3">
<div class="flex-1">
<label for="name" class="block text-xs text-slate-400 mb-1.5">Key Label</label>
<input type="text" id="name" name="name" value="{{ old('name') }}"
placeholder="e.g. My App, Personal Script"
class="w-full bg-[#0f0f13] border border-white/10 rounded-lg px-3 py-2 text-sm text-slate-200 placeholder-slate-500 focus:outline-none focus:border-purple-500 focus:ring-1 focus:ring-purple-500 transition"
maxlength="50">
@error('name')
<p class="text-red-400 text-xs mt-1">{{ $message }}</p>
@enderror
</div>
<button type="submit" class="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white text-sm rounded-lg transition-colors font-medium whitespace-nowrap">
Generate Key
</button>
</div>
</form>
</div>
</div>
@endsection
```
---
### Task 10: `GameApiController`
**Files:**
- Create: `app/Http/Controllers/Api/V1/GameApiController.php`
- [ ] **Step 1: Create directory and controller**
```php
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\Game;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class GameApiController extends Controller
{
private function formatGame(Game $game): array
{
return [
'id' => $game->id,
'title' => $game->title,
'slug' => $game->slug,
'cover_url' => $game->cover_url,
'release_year' => $game->release_year,
'release_date' => $game->release_date?->toDateString(),
'rating_avg' => $game->rating_avg ? (float) $game->rating_avg : null,
'rating_count' => $game->rating_count,
'library_count' => $game->library_count,
'genres' => $game->genres->pluck('name'),
'platforms' => $game->platforms->pluck('name'),
];
}
public function index(Request $request): JsonResponse
{
$query = Game::with(['genres', 'platforms'])->published();
if ($request->filled('genre')) {
$query->whereHas('genres', fn ($q) => $q->where('genres.id', $request->genre));
}
if ($request->filled('platform')) {
$query->whereHas('platforms', fn ($q) => $q->where('platforms.id', $request->platform));
}
if ($request->filled('year')) {
$query->where('release_year', $request->year);
}
match ($request->get('sort', 'popular')) {
'rating' => $query->orderByDesc('rating_avg'),
'release' => $query->orderByDesc('release_date'),
'title' => $query->orderBy('title'),
default => $query->orderByDesc('library_count'),
};
$paginator = $query->paginate(20);
return response()->json([
'data' => $paginator->map(fn ($game) => $this->formatGame($game))->values(),
'meta' => [
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
],
]);
}
public function show(Game $game): JsonResponse
{
$game->load(['genres', 'platforms', 'companies', 'screenshots', 'reviews.user']);
return response()->json([
'data' => array_merge($this->formatGame($game), [
'description' => $game->summary,
'companies' => $game->companies->map(fn ($c) => [
'name' => $c->name,
'role' => $c->pivot->role,
]),
'screenshots' => $game->screenshots->pluck('url'),
'reviews' => $game->reviews->take(5)->map(fn ($r) => [
'user' => $r->user->slug,
'body' => $r->body,
'rating' => $r->rating,
'created_at' => $r->created_at->toIso8601String(),
]),
]),
]);
}
public function search(Request $request): JsonResponse
{
$q = trim((string) $request->get('q', ''));
if (mb_strlen($q) < 2) {
return response()->json(['error' => 'Query must be at least 2 characters.'], 422);
}
$games = Game::with(['genres', 'platforms'])
->where('title', 'like', '%' . $q . '%')
->published()
->orderBy('title')
->limit(20)
->get();
return response()->json([
'data' => $games->map(fn ($game) => $this->formatGame($game))->values(),
'meta' => ['total' => $games->count()],
]);
}
public function topRated(Request $request): JsonResponse
{
$paginator = Game::with(['genres', 'platforms'])
->published()
->where('rating_count', '>=', 1)
->orderByDesc('rating_avg')
->orderByDesc('rating_count')
->paginate(20);
return response()->json([
'data' => $paginator->map(fn ($game) => $this->formatGame($game))->values(),
'meta' => [
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
],
]);
}
public function newReleases(Request $request): JsonResponse
{
$paginator = Game::with(['genres', 'platforms'])
->published()
->where('release_date', '<=', now())
->orderByDesc('release_date')
->paginate(20);
return response()->json([
'data' => $paginator->map(fn ($game) => $this->formatGame($game))->values(),
'meta' => [
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
],
]);
}
}
```
---
### Task 11: `MetaApiController`
**Files:**
- Create: `app/Http/Controllers/Api/V1/MetaApiController.php`
- [ ] **Step 1: Create controller**
```php
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\Genre;
use App\Models\Platform;
use Illuminate\Http\JsonResponse;
class MetaApiController extends Controller
{
public function genres(): JsonResponse
{
return response()->json([
'data' => Genre::orderBy('name')->get(['id', 'name', 'slug']),
]);
}
public function platforms(): JsonResponse
{
return response()->json([
'data' => Platform::orderBy('name')->get(['id', 'name', 'slug']),
]);
}
}
```
---
### Task 12: `UserApiController`
**Files:**
- Create: `app/Http/Controllers/Api/V1/UserApiController.php`
- [ ] **Step 1: Create controller**
```php
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class UserApiController extends Controller
{
public function show(User $user): JsonResponse
{
$library = $user->library();
return response()->json([
'data' => [
'username' => $user->slug,
'name' => $user->name,
'avatar_url' => $user->avatar,
'joined_at' => $user->created_at->toIso8601String(),
'stats' => [
'played' => (clone $library)->where('status', 'played')->count(),
'playing' => (clone $library)->where('status', 'playing')->count(),
'want_to_play' => (clone $library)->where('status', 'want_to_play')->count(),
'total' => $user->library()->count(),
],
],
]);
}
public function library(User $user, Request $request): JsonResponse
{
$paginator = $user->library()
->with('game')
->orderByDesc('updated_at')
->paginate(20);
return response()->json([
'data' => $paginator->map(fn ($ug) => [
'game' => [
'title' => $ug->game->title,
'slug' => $ug->game->slug,
'cover_url' => $ug->game->cover_url,
],
'status' => $ug->status,
'rating' => $ug->rating,
'updated_at' => $ug->updated_at->toIso8601String(),
])->values(),
'meta' => [
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
],
]);
}
}
```
---
### Task 13: API docs page
**Files:**
- Create: `resources/views/api/docs.blade.php`
- [ ] **Step 1: Add route to `web.php` (public, no auth)**
Add near the top of `routes/web.php`:
```php
Route::get('/api/docs', function () {
return view('api.docs');
})->name('api.docs');
```
- [ ] **Step 2: Create the docs view**
```blade
@extends('layouts.app')
@section('title', 'API Documentation — LoreKeepers')
@section('content')
<div class="max-w-4xl mx-auto px-4 py-10">
<div class="mb-10">
<h1 class="text-3xl font-bold text-white mb-3">API Documentation</h1>
<p class="text-slate-400">Programmatic access to the LoreKeepers game database and user libraries.</p>
</div>
{{-- Auth --}}
<section class="mb-10">
<h2 class="text-xl font-semibold text-white mb-4 pb-2 border-b border-white/10">Authentication</h2>
<p class="text-slate-400 text-sm mb-3">All API requests require an API key. Generate one in <a href="{{ route('settings.api-keys') }}" class="text-purple-400 hover:text-purple-300 underline">Settings → API Keys</a>.</p>
<p class="text-slate-400 text-sm mb-3">Pass the key via the <code class="text-purple-300 bg-white/5 px-1.5 py-0.5 rounded text-xs">Authorization</code> header:</p>
<pre class="bg-[#16161d] border border-white/10 rounded-xl p-4 text-sm text-green-300 overflow-x-auto font-mono">Authorization: Bearer YOUR_API_KEY</pre>
<p class="text-slate-500 text-sm mt-3">Alternatively, append <code class="text-purple-300 bg-white/5 px-1.5 py-0.5 rounded text-xs">?api_key=YOUR_API_KEY</code> as a query parameter.</p>
</section>
{{-- Base URL + Rate Limit --}}
<section class="mb-10">
<h2 class="text-xl font-semibold text-white mb-4 pb-2 border-b border-white/10">Base URL &amp; Rate Limiting</h2>
<div class="grid md:grid-cols-2 gap-4">
<div class="bg-[#16161d] border border-white/5 rounded-xl p-4">
<div class="text-xs text-slate-500 mb-1 uppercase tracking-wide">Base URL</div>
<code class="text-purple-300 font-mono text-sm">https://gamelog.ro/api/v1</code>
</div>
<div class="bg-[#16161d] border border-white/5 rounded-xl p-4">
<div class="text-xs text-slate-500 mb-1 uppercase tracking-wide">Rate Limit</div>
<span class="text-white text-sm font-medium">60 requests / minute</span>
<p class="text-slate-500 text-xs mt-1">Returns HTTP 429 when exceeded.</p>
</div>
</div>
</section>
{{-- Endpoints --}}
<section class="mb-10">
<h2 class="text-xl font-semibold text-white mb-4 pb-2 border-b border-white/10">Endpoints</h2>
@php
$endpoints = [
['GET', '/games', 'List games', 'Filter: genre (id), platform (id), year, sort (rating|release|title|popular). Paginated 20/page.'],
['GET', '/games/search', 'Search games', 'Required: q (min 2 chars). Returns up to 20 matches.'],
['GET', '/games/top-rated', 'Top rated games', 'Games with ≥1 rating, sorted by avg rating. Paginated 20/page.'],
['GET', '/games/new-releases', 'New releases', 'Games released ≤ today, sorted newest first. Paginated 20/page.'],
['GET', '/games/{slug}', 'Game detail', 'Full game info: companies, screenshots, latest 5 reviews.'],
['GET', '/genres', 'All genres', 'Returns id, name, slug for all genres.'],
['GET', '/platforms', 'All platforms', 'Returns id, name, slug for all platforms.'],
['GET', '/users/{slug}', 'User profile', 'Public profile: username, name, avatar, stats, joined date.'],
['GET', '/users/{slug}/library', 'User library', 'User\'s game library. Paginated 20/page. Returns game + status + rating.'],
];
@endphp
<div class="space-y-3">
@foreach($endpoints as $ep)
<div class="bg-[#16161d] border border-white/5 rounded-xl p-4 flex gap-4 items-start">
<span class="flex-shrink-0 text-xs font-mono font-bold px-2 py-1 rounded bg-purple-900/40 text-purple-300 border border-purple-700/30 mt-0.5">{{ $ep[0] }}</span>
<div class="min-w-0">
<code class="text-green-300 font-mono text-sm">/api/v1{{ $ep[1] }}</code>
<div class="text-white text-sm font-medium mt-0.5">{{ $ep[2] }}</div>
<div class="text-slate-500 text-xs mt-1">{{ $ep[3] }}</div>
</div>
</div>
@endforeach
</div>
</section>
{{-- Response format --}}
<section class="mb-10">
<h2 class="text-xl font-semibold text-white mb-4 pb-2 border-b border-white/10">Response Format</h2>
<p class="text-slate-400 text-sm mb-4">All list endpoints return paginated responses wrapped in <code class="text-purple-300 bg-white/5 px-1.5 py-0.5 rounded text-xs">data</code> + <code class="text-purple-300 bg-white/5 px-1.5 py-0.5 rounded text-xs">meta</code>:</p>
<pre class="bg-[#16161d] border border-white/10 rounded-xl p-4 text-sm text-green-300 overflow-x-auto font-mono">{
"data": [ ... ],
"meta": {
"current_page": 1,
"last_page": 5,
"per_page": 20,
"total": 93
}
}</pre>
</section>
{{-- Example --}}
<section class="mb-10">
<h2 class="text-xl font-semibold text-white mb-4 pb-2 border-b border-white/10">Example Request</h2>
<pre class="bg-[#16161d] border border-white/10 rounded-xl p-4 text-sm text-green-300 overflow-x-auto font-mono">curl https://gamelog.ro/api/v1/games/top-rated \
-H "Authorization: Bearer YOUR_API_KEY"</pre>
</section>
{{-- Errors --}}
<section>
<h2 class="text-xl font-semibold text-white mb-4 pb-2 border-b border-white/10">Error Codes</h2>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-left text-slate-500 text-xs uppercase tracking-wide border-b border-white/10">
<th class="pb-2 pr-6">Status</th>
<th class="pb-2">Meaning</th>
</tr>
</thead>
<tbody class="text-slate-300">
<tr class="border-b border-white/5"><td class="py-2.5 pr-6 font-mono text-yellow-400">401</td><td>Missing or invalid API key</td></tr>
<tr class="border-b border-white/5"><td class="py-2.5 pr-6 font-mono text-yellow-400">404</td><td>Resource not found</td></tr>
<tr class="border-b border-white/5"><td class="py-2.5 pr-6 font-mono text-yellow-400">422</td><td>Validation error (e.g. q too short)</td></tr>
<tr><td class="py-2.5 pr-6 font-mono text-yellow-400">429</td><td>Rate limit exceeded (60 req/min)</td></tr>
</tbody>
</table>
</div>
</section>
</div>
@endsection
```
---
### Task 14: Add "API" link to nav and footer
**Files:**
- Modify: `resources/views/layouts/app.blade.php`
- [ ] **Step 1: Add to footer links**
Find this block in `resources/views/layouts/app.blade.php` (around line 185):
```blade
<div class="flex items-center gap-6 text-sm text-slate-500">
<a href="{{ route('games.index') }}" class="hover:text-slate-300 transition-colors">Browse</a>
<a href="{{ route('games.top-rated') }}" class="hover:text-slate-300 transition-colors">Top Rated</a>
<a href="{{ route('games.new-releases') }}" class="hover:text-slate-300 transition-colors">New Releases</a>
</div>
```
Replace with:
```blade
<div class="flex items-center gap-6 text-sm text-slate-500">
<a href="{{ route('games.index') }}" class="hover:text-slate-300 transition-colors">Browse</a>
<a href="{{ route('games.top-rated') }}" class="hover:text-slate-300 transition-colors">Top Rated</a>
<a href="{{ route('games.new-releases') }}" class="hover:text-slate-300 transition-colors">New Releases</a>
<a href="{{ route('api.docs') }}" class="hover:text-slate-300 transition-colors">Developers</a>
</div>
```
- [ ] **Step 2: Add "API Keys" link to user dropdown (after Settings)**
Find in `resources/views/layouts/app.blade.php` (around line 140):
```blade
<a href="{{ route('profile.edit') }}" class="block px-4 py-2.5 text-sm text-slate-300 hover:bg-white/5 hover:text-white transition-colors">Settings</a>
```
Replace with:
```blade
<a href="{{ route('profile.edit') }}" class="block px-4 py-2.5 text-sm text-slate-300 hover:bg-white/5 hover:text-white transition-colors">Settings</a>
<a href="{{ route('settings.api-keys') }}" class="block px-4 py-2.5 text-sm text-slate-300 hover:bg-white/5 hover:text-white transition-colors">API Keys</a>
```
---
### Task 15: Verify and commit
- [ ] **Step 1: Run syntax check**
```bash
php artisan route:list --path=api
```
Expected: list of `/api/v1/*` routes showing `api.key` and `throttle:api` middleware.
- [ ] **Step 2: Run migrations**
```bash
php artisan migrate:status | grep api_keys
```
Expected: `Yes` in the `Ran?` column for `2026_05_31_000001_create_api_keys_table`.
- [ ] **Step 3: Commit**
```bash
git add -A
git commit -m "Add public API v1 with API key auth, rate limiting, and docs page"
```
- [ ] **Step 4: Push**
```bash
git -c http.extraHeader="Authorization: token cc6bd9ac81340acb043c5f630586b2d5f1cb8280" push origin master
```