diff --git a/app/Http/Controllers/Api/V1/GameApiController.php b/app/Http/Controllers/Api/V1/GameApiController.php new file mode 100644 index 0000000..b1ae0c6 --- /dev/null +++ b/app/Http/Controllers/Api/V1/GameApiController.php @@ -0,0 +1,146 @@ + $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(), + ], + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/MetaApiController.php b/app/Http/Controllers/Api/V1/MetaApiController.php new file mode 100644 index 0000000..9fc62af --- /dev/null +++ b/app/Http/Controllers/Api/V1/MetaApiController.php @@ -0,0 +1,25 @@ +json([ + 'data' => Genre::orderBy('name')->get(['id', 'name', 'slug']), + ]); + } + + public function platforms(): JsonResponse + { + return response()->json([ + 'data' => Platform::orderBy('name')->get(['id', 'name', 'slug']), + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/UserApiController.php b/app/Http/Controllers/Api/V1/UserApiController.php new file mode 100644 index 0000000..f36cffc --- /dev/null +++ b/app/Http/Controllers/Api/V1/UserApiController.php @@ -0,0 +1,58 @@ +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(), + ], + ]); + } +} diff --git a/app/Http/Controllers/ApiKeyController.php b/app/Http/Controllers/ApiKeyController.php new file mode 100644 index 0000000..65b0d1b --- /dev/null +++ b/app/Http/Controllers/ApiKeyController.php @@ -0,0 +1,32 @@ +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.'); + } +} diff --git a/app/Http/Middleware/AuthenticateApiKey.php b/app/Http/Middleware/AuthenticateApiKey.php new file mode 100644 index 0000000..2f09685 --- /dev/null +++ b/app/Http/Middleware/AuthenticateApiKey.php @@ -0,0 +1,31 @@ +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); + } +} diff --git a/app/Models/ApiKey.php b/app/Models/ApiKey.php new file mode 100644 index 0000000..c8f4144 --- /dev/null +++ b/app/Models/ApiKey.php @@ -0,0 +1,25 @@ +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)), + ]); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 069cad4..c099f08 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -19,6 +19,7 @@ class User extends Authenticatable public function library(): HasMany { return $this->hasMany(UserGame::class); } public function reviews(): HasMany { return $this->hasMany(Review::class); } public function lists(): HasMany { return $this->hasMany(UserList::class); } + public function apiKeys(): HasMany { return $this->hasMany(\App\Models\ApiKey::class); } public function activities(): HasMany { return $this->hasMany(Activity::class)->latest(); } public function notifications(): HasMany { return $this->hasMany(Notification::class)->latest('created_at'); } diff --git a/app/Policies/ApiKeyPolicy.php b/app/Policies/ApiKeyPolicy.php new file mode 100644 index 0000000..d6ec071 --- /dev/null +++ b/app/Policies/ApiKeyPolicy.php @@ -0,0 +1,14 @@ +id === $apiKey->user_id; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 6cc9d94..7f7e731 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -4,7 +4,10 @@ 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 @@ -26,5 +29,13 @@ class AppServiceProvider extends ServiceProvider 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); + Gate::policy(\App\Models\ApiKey::class, \App\Policies\ApiKeyPolicy::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); + }); + }); } } diff --git a/bootstrap/app.php b/bootstrap/app.php index 7a2848f..8c35ec0 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -12,7 +12,9 @@ return Application::configure(basePath: dirname(__DIR__)) health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { - // + $middleware->alias([ + 'api.key' => \App\Http\Middleware\AuthenticateApiKey::class, + ]); }) ->withExceptions(function (Exceptions $exceptions): void { $exceptions->shouldRenderJsonWhen( diff --git a/database/migrations/2026_05_31_000001_create_api_keys_table.php b/database/migrations/2026_05_31_000001_create_api_keys_table.php new file mode 100644 index 0000000..5d0594d --- /dev/null +++ b/database/migrations/2026_05_31_000001_create_api_keys_table.php @@ -0,0 +1,25 @@ +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'); + } +}; diff --git a/docs/superpowers/plans/2026-05-31-public-api-v1.md b/docs/superpowers/plans/2026-05-31-public-api-v1.md new file mode 100644 index 0000000..21d1b4d --- /dev/null +++ b/docs/superpowers/plans/2026-05-31-public-api-v1.md @@ -0,0 +1,998 @@ +# 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 +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 +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 +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 +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 +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 +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 +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 +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 +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') +
+ +

API Keys

+

Use API keys to access the LoreKeepers API. Keep them secret — treat them like passwords.

+ + {{-- New key revealed --}} + @if(session('new_api_key')) +
+
+ + + + This key will only be shown once. Copy it now. +
+
+ {{ session('new_api_key') }} + +
+
+ @endif + + {{-- Existing keys --}} +
+
+

Your API Keys

+
+ @forelse($keys as $key) +
+
+
{{ $key->name }}
+
+ Created {{ $key->created_at->format('M j, Y') }} + @if($key->last_used_at) + · Last used {{ $key->last_used_at->diffForHumans() }} + @else + · Never used + @endif +
+
+
+ @csrf + @method('DELETE') + +
+
+ @empty +
+ No API keys yet. Create one below. +
+ @endforelse +
+ + {{-- Create new key --}} +
+
+

Create New Key

+
+
+ @csrf +
+
+ + + @error('name') +

{{ $message }}

+ @enderror +
+ +
+
+
+ +
+@endsection +``` + +--- + +### Task 10: `GameApiController` + +**Files:** +- Create: `app/Http/Controllers/Api/V1/GameApiController.php` + +- [ ] **Step 1: Create directory and controller** + +```php + $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 +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 +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') +
+ +
+

API Documentation

+

Programmatic access to the LoreKeepers game database and user libraries.

+
+ + {{-- Auth --}} +
+

Authentication

+

All API requests require an API key. Generate one in Settings → API Keys.

+

Pass the key via the Authorization header:

+
Authorization: Bearer YOUR_API_KEY
+

Alternatively, append ?api_key=YOUR_API_KEY as a query parameter.

+
+ + {{-- Base URL + Rate Limit --}} +
+

Base URL & Rate Limiting

+
+
+
Base URL
+ https://gamelog.ro/api/v1 +
+
+
Rate Limit
+ 60 requests / minute +

Returns HTTP 429 when exceeded.

+
+
+
+ + {{-- Endpoints --}} +
+

Endpoints

+ + @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 + +
+ @foreach($endpoints as $ep) +
+ {{ $ep[0] }} +
+ /api/v1{{ $ep[1] }} +
{{ $ep[2] }}
+
{{ $ep[3] }}
+
+
+ @endforeach +
+
+ + {{-- Response format --}} +
+

Response Format

+

All list endpoints return paginated responses wrapped in data + meta:

+
{
+  "data": [ ... ],
+  "meta": {
+    "current_page": 1,
+    "last_page": 5,
+    "per_page": 20,
+    "total": 93
+  }
+}
+
+ + {{-- Example --}} +
+

Example Request

+
curl https://gamelog.ro/api/v1/games/top-rated \
+  -H "Authorization: Bearer YOUR_API_KEY"
+
+ + {{-- Errors --}} +
+

Error Codes

+
+ + + + + + + + + + + + + +
StatusMeaning
401Missing or invalid API key
404Resource not found
422Validation error (e.g. q too short)
429Rate limit exceeded (60 req/min)
+
+
+ +
+@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 +
+ Browse + Top Rated + New Releases +
+``` + +Replace with: + +```blade +
+ Browse + Top Rated + New Releases + Developers +
+``` + +- [ ] **Step 2: Add "API Keys" link to user dropdown (after Settings)** + +Find in `resources/views/layouts/app.blade.php` (around line 140): + +```blade + Settings +``` + +Replace with: + +```blade + Settings + API Keys +``` + +--- + +### 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 +``` diff --git a/resources/views/api/docs.blade.php b/resources/views/api/docs.blade.php new file mode 100644 index 0000000..f29fe47 --- /dev/null +++ b/resources/views/api/docs.blade.php @@ -0,0 +1,108 @@ +@extends('layouts.app') + +@section('title', 'API Documentation — LoreKeepers') + +@section('content') +
+ +
+

API Documentation

+

Programmatic access to the LoreKeepers game database and user libraries.

+
+ +
+

Authentication

+

All API requests require an API key. @auth Generate one in Settings → API Keys. @else Sign up and generate one in Settings → API Keys. @endauth

+

Pass the key via the Authorization header:

+
Authorization: Bearer YOUR_API_KEY
+

Alternatively, append ?api_key=YOUR_API_KEY as a query parameter.

+
+ +
+

Base URL & Rate Limiting

+
+
+
Base URL
+ https://gamelog.ro/api/v1 +
+
+
Rate Limit
+ 60 requests / minute +

Returns HTTP 429 when exceeded.

+
+
+
+ +
+

Endpoints

+ + @php + $endpoints = [ + ['GET', '/games', 'List games', 'Filters: 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 + +
+ @foreach($endpoints as $ep) +
+ {{ $ep[0] }} +
+ /api/v1{{ $ep[1] }} +
{{ $ep[2] }}
+
{{ $ep[3] }}
+
+
+ @endforeach +
+
+ +
+

Response Format

+

All list endpoints return paginated responses wrapped in data + meta:

+
{
+  "data": [ ... ],
+  "meta": {
+    "current_page": 1,
+    "last_page": 5,
+    "per_page": 20,
+    "total": 93
+  }
+}
+
+ +
+

Example Request

+
curl https://gamelog.ro/api/v1/games/top-rated \
+  -H "Authorization: Bearer YOUR_API_KEY"
+
+ +
+

Error Codes

+
+ + + + + + + + + + + + + +
StatusMeaning
401Missing or invalid API key
404Resource not found
422Validation error (e.g. q too short)
429Rate limit exceeded (60 req/min)
+
+
+ +
+@endsection diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index badcd73..9e88964 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -138,6 +138,7 @@ My Lists @endif Settings + API Keys
@csrf @@ -186,6 +187,7 @@ Browse Top Rated New Releases + Developers diff --git a/resources/views/settings/api-keys.blade.php b/resources/views/settings/api-keys.blade.php new file mode 100644 index 0000000..58576aa --- /dev/null +++ b/resources/views/settings/api-keys.blade.php @@ -0,0 +1,87 @@ +@extends('layouts.app') + +@section('title', 'API Keys — LoreKeepers') + +@section('content') +
+ +

API Keys

+

Use API keys to access the LoreKeepers API. Keep them secret — treat them like passwords.

+ + @if(session('new_api_key')) +
+
+ + + + This key will only be shown once. Copy it now. +
+
+ {{ session('new_api_key') }} + +
+
+ @endif + +
+
+

Your API Keys

+
+ @forelse($keys as $key) +
+
+
{{ $key->name }}
+
+ Created {{ $key->created_at->format('M j, Y') }} + @if($key->last_used_at) + · Last used {{ $key->last_used_at->diffForHumans() }} + @else + · Never used + @endif +
+
+ + @csrf + @method('DELETE') + + +
+ @empty +
+ No API keys yet. Create one below. +
+ @endforelse +
+ +
+
+

Create New Key

+
+
+ @csrf +
+
+ + + @error('name') +

{{ $message }}

+ @enderror +
+ +
+
+
+ +
+@endsection diff --git a/routes/api.php b/routes/api.php new file mode 100644 index 0000000..a28a39c --- /dev/null +++ b/routes/api.php @@ -0,0 +1,23 @@ +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']); +}); diff --git a/routes/web.php b/routes/web.php index b229812..57c03eb 100644 --- a/routes/web.php +++ b/routes/web.php @@ -81,6 +81,18 @@ Route::get('/sitemap.xml', function () { return response()->file($path, ['Content-Type' => 'application/xml']); })->name('sitemap'); +// 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'); +}); + +// API Docs (public) +Route::get('/api/docs', function () { + return view('api.docs'); +})->name('api.docs'); + // Dashboard (Breeze default, keep for now) Route::get('/dashboard', function () { return redirect()->route('home'); @@ -94,3 +106,4 @@ Route::middleware('auth')->group(function () { }); require __DIR__.'/auth.php'; +require __DIR__.'/api.php';