* Versioned REST API under /api/v1
* Published Pages API
* Published Blog API
* Published News API
* Secure bearer API tokens
* SHA-256 token storage
* Token abilities
* Token expiry and revocation
* Token usage tracking
* Webhook subscriptions
* Site-specific webhooks
* HMAC-SHA256 webhook signatures
* Webhook delivery history
* Failure tracking
* OpenAPI 3.1 JSON documentation
* API administration screen
* Administration navigation
* API permissions
* Module registration
* Feature tests
This commit is contained in:
Mark Gregory
2026-07-24 12:09:36 +01:00
parent c332e6b57a
commit 2b949cb354
22 changed files with 70 additions and 2 deletions
@@ -0,0 +1,4 @@
<?php
namespace Modules\Api\Http\Controllers;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Str; use Illuminate\View\View; use Modules\Api\Models\ApiToken; use Modules\Api\Models\Webhook;
final class ApiController {public function index(): View{return view('api::admin.index',['tokens'=>ApiToken::with('user')->latest()->paginate(20,'*','tokens'),'webhooks'=>Webhook::latest()->paginate(20,'*','webhooks')]);}public function storeToken(Request $r): RedirectResponse{$d=$r->validate(['name'=>'required|string|max:255','abilities'=>'nullable|string','expires_at'=>'nullable|date|after:now']);$abilities=array_values(array_filter(array_map('trim',explode(',',$d['abilities']?:'*'))));[,$plain]=ApiToken::issue($r->user(),$d['name'],$abilities,isset($d['expires_at'])?new \DateTimeImmutable($d['expires_at']):null);return back()->with('api_token',$plain)->with('status','API token created.');}public function destroyToken(ApiToken $token): RedirectResponse{$token->delete();return back()->with('status','API token revoked.');}public function storeWebhook(Request $r): RedirectResponse{$d=$r->validate(['name'=>'required|string|max:255','url'=>'required|url|max:2048','events'=>'required|string','site_id'=>'nullable|integer|exists:sites,id']);Webhook::create(['name'=>$d['name'],'url'=>$d['url'],'events'=>array_values(array_filter(array_map('trim',explode(',',$d['events'])))),'site_id'=>$d['site_id']??null,'secret'=>Str::random(48),'is_active'=>true]);return back()->with('status','Webhook created.');}public function destroyWebhook(Webhook $webhook): RedirectResponse{$webhook->delete();return back()->with('status','Webhook deleted.');}}
@@ -0,0 +1,4 @@
<?php
namespace Modules\Api\Http\Controllers;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; use Modules\Api\Http\Resources\ContentResource; use Modules\Blog\Models\Blog; use Modules\News\Models\News; use Modules\Pages\Models\Page;
final class ContentController {public function pages(): AnonymousResourceCollection{return $this->many(Page::query()->where('status','published'));}public function blog(): AnonymousResourceCollection{return $this->many(Blog::query()->published());}public function news(): AnonymousResourceCollection{return $this->many(News::query()->published());}public function page(Page $page): ContentResource{abort_unless($page->status==='published',404);return new ContentResource($page);}public function blogPost(Blog $blog): ContentResource{abort_unless(Blog::query()->published()->whereKey($blog)->exists(),404);return new ContentResource($blog);}public function newsArticle(News $news): ContentResource{abort_unless(News::query()->published()->whereKey($news)->exists(),404);return new ContentResource($news);}private function many(Builder $q): AnonymousResourceCollection{return ContentResource::collection($q->latest('updated_at')->paginate((int)config('api.pagination',20)));}}
@@ -0,0 +1,4 @@
<?php
namespace Modules\Api\Http\Controllers;
use Illuminate\Http\JsonResponse;
final class OpenApiController {public function __invoke(): JsonResponse{return response()->json(['openapi'=>'3.1.0','info'=>config('api.openapi'),'servers'=>[['url'=>url('/'.config('api.prefix','api/v1'))]],'components'=>['securitySchemes'=>['bearerAuth'=>['type'=>'http','scheme'=>'bearer']]],'paths'=>['/pages'=>['get'=>['summary'=>'List published pages','responses'=>['200'=>['description'=>'OK']]]],'/blog'=>['get'=>['summary'=>'List published blog posts','responses'=>['200'=>['description'=>'OK']]]],'/news'=>['get'=>['summary'=>'List published news','responses'=>['200'=>['description'=>'OK']]]],'/user'=>['get'=>['summary'=>'Authenticated user','security'=>[['bearerAuth'=>[]]],'responses'=>['200'=>['description'=>'OK'],'401'=>['description'=>'Unauthenticated']]]]]]);}}
@@ -0,0 +1,4 @@
<?php
namespace Modules\Api\Http\Middleware;
use Closure; use Illuminate\Http\Request; use Modules\Api\Models\ApiToken; use Symfony\Component\HttpFoundation\Response;
final class AuthenticateApiToken {public function handle(Request $r,Closure $next,?string $ability=null): Response{$plain=$r->bearerToken();if(!$plain)return response()->json(['message'=>'Unauthenticated.'],401);$token=ApiToken::with('user')->where('token',hash('sha256',$plain))->first();if(!$token||($token->expires_at&&$token->expires_at->isPast())||!$token->user)return response()->json(['message'=>'Invalid or expired API token.'],401);if($ability&&!$token->allows($ability))return response()->json(['message'=>'Insufficient token ability.'],403);$token->update(['last_used_at'=>now()]);auth()->setUser($token->user);$r->attributes->set('apiToken',$token);return $next($r);}}
@@ -0,0 +1,4 @@
<?php
namespace Modules\Api\Http\Resources;
use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource;
final class ContentResource extends JsonResource {public function toArray(Request $r): array{return ['id'=>$this->id,'type'=>$this->resource->getTable(),'title'=>$this->title,'slug'=>$this->slug,'excerpt'=>$this->excerpt??null,'content'=>$this->content??null,'status'=>$this->status??null,'featured_image'=>$this->featured_image??null,'published_at'=>$this->published_at??null,'updated_at'=>$this->updated_at];}}