Add public API v1 with API key auth, rate limiting, and docs page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
68d01448d2
commit
a583a13da1
@@ -0,0 +1,146 @@
|
||||
<?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(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?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']),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?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(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?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.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
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)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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'); }
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -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(
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?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');
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
<?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 & 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
|
||||
```
|
||||
@@ -0,0 +1,108 @@
|
||||
@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>
|
||||
|
||||
<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. @auth Generate one in <a href="{{ route('settings.api-keys') }}" class="text-purple-400 hover:text-purple-300 underline">Settings → API Keys</a>. @else <a href="{{ route('register') }}" class="text-purple-400 hover:text-purple-300 underline">Sign up</a> and generate one in Settings → API Keys. @endauth</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>
|
||||
|
||||
<section class="mb-10">
|
||||
<h2 class="text-xl font-semibold text-white mb-4 pb-2 border-b border-white/10">Base URL & 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>
|
||||
|
||||
<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', '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
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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
|
||||
@@ -138,6 +138,7 @@
|
||||
<a href="{{ route('lists.index') }}" class="block px-4 py-2.5 text-sm text-slate-300 hover:bg-white/5 hover:text-white transition-colors">My Lists</a>
|
||||
@endif
|
||||
<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>
|
||||
<div class="border-t border-white/5"></div>
|
||||
<form method="POST" action="{{ route('logout') }}">
|
||||
@csrf
|
||||
@@ -186,6 +187,7 @@
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
@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>
|
||||
|
||||
@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" style="display:none">Copied!</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<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>
|
||||
|
||||
<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
|
||||
@@ -0,0 +1,23 @@
|
||||
<?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']);
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user