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:
Mark
2026-05-31 23:29:18 +03:00
co-authored by Claude Sonnet 4.6
parent 68d01448d2
commit a583a13da1
17 changed files with 1602 additions and 1 deletions
+108
View File
@@ -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 &amp; Rate Limiting</h2>
<div class="grid md:grid-cols-2 gap-4">
<div class="bg-[#16161d] border border-white/5 rounded-xl p-4">
<div class="text-xs text-slate-500 mb-1 uppercase tracking-wide">Base URL</div>
<code class="text-purple-300 font-mono text-sm">https://gamelog.ro/api/v1</code>
</div>
<div class="bg-[#16161d] border border-white/5 rounded-xl p-4">
<div class="text-xs text-slate-500 mb-1 uppercase tracking-wide">Rate Limit</div>
<span class="text-white text-sm font-medium">60 requests / minute</span>
<p class="text-slate-500 text-xs mt-1">Returns HTTP 429 when exceeded.</p>
</div>
</div>
</section>
<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
+2
View File
@@ -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