feat(api): REST API v1 — links CRUD with Sanctum auth

Adds /api/v1/workspaces/{ulid}/links endpoints (index, store, destroy)
protected by Sanctum token auth; adds HasApiTokens to User model and
AuthorizesRequests to base Controller; wires routes/api.php into bootstrap.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
boban 2026-05-16 07:38:45 +02:00
parent 8f1398380f
commit 1375519cdb
8 changed files with 150 additions and 2 deletions

View File

@ -0,0 +1,22 @@
<?php
namespace App\Domains\Api\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
class LinkResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => $this->ulid,
'slug' => $this->slug,
'target_url' => $this->target_url,
'short_url' => 'https://' . config('app.short_link_domain', 'nimu.li') . '/' . $this->slug,
'status' => $this->status,
'title' => $this->title,
'tags' => $this->tags ?? [],
'created_at' => $this->created_at?->toISOString(),
];
}
}

View File

@ -0,0 +1,57 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Domains\Api\Resources\LinkResource;
use App\Domains\Link\Actions\CreateLink;
use App\Domains\Link\Models\Link;
use App\Domains\Workspace\Models\Workspace;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class LinkController extends Controller
{
public function index(Request $request, string $workspaceUlid): AnonymousResourceCollection
{
$workspace = Workspace::where('ulid', $workspaceUlid)->firstOrFail();
$this->authorize('view', $workspace);
$links = $workspace->links()->latest()->paginate(50);
return LinkResource::collection($links);
}
public function store(Request $request, string $workspaceUlid, CreateLink $action): \Illuminate\Http\JsonResponse
{
$workspace = Workspace::where('ulid', $workspaceUlid)->firstOrFail();
$this->authorize('update', $workspace);
$validated = $request->validate([
'target_url' => 'required|url|max:2048',
'slug' => 'nullable|string|max:64|alpha_dash',
'title' => 'nullable|string|max:255',
'tags' => 'nullable|array',
]);
$link = $action->handle($workspace, $request->user(), $validated);
return (new LinkResource($link))
->response()
->setStatusCode(201);
}
public function destroy(string $workspaceUlid, string $linkUlid): \Illuminate\Http\Response
{
$workspace = Workspace::where('ulid', $workspaceUlid)->firstOrFail();
$this->authorize('update', $workspace);
$link = Link::where('ulid', $linkUlid)
->where('workspace_id', $workspace->id)
->firstOrFail();
$link->delete();
return response()->noContent();
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Domains\Workspace\Models\Workspace;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class WorkspaceController extends Controller
{
public function index(Request $request): \Illuminate\Http\JsonResponse
{
$workspaces = $request->user()
->workspaces()
->get(['id', 'ulid', 'name', 'slug']);
return response()->json(['data' => $workspaces]);
}
}

View File

@ -4,5 +4,5 @@ namespace App\Http\Controllers;
abstract class Controller
{
//
use \Illuminate\Foundation\Auth\Access\AuthorizesRequests;
}

View File

@ -10,11 +10,12 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable
{
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable, SoftDeletes;
use HasApiTokens, HasFactory, Notifiable, SoftDeletes;
protected $guarded = ['id'];

View File

@ -7,6 +7,7 @@ use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
channels: __DIR__.'/../routes/channels.php',
health: '/up',

15
routes/api.php Normal file
View File

@ -0,0 +1,15 @@
<?php
use App\Http\Controllers\Api\V1\LinkController;
use App\Http\Controllers\Api\V1\WorkspaceController;
use Illuminate\Support\Facades\Route;
Route::prefix('v1')->middleware(['auth:sanctum', 'throttle:60,1'])->group(function () {
Route::get('workspaces', [WorkspaceController::class, 'index']);
Route::prefix('workspaces/{workspaceUlid}')->group(function () {
Route::get('links', [LinkController::class, 'index']);
Route::post('links', [LinkController::class, 'store']);
Route::delete('links/{linkUlid}', [LinkController::class, 'destroy']);
});
});

View File

@ -0,0 +1,33 @@
<?php
use App\Domains\Link\Models\Link;
use App\Domains\Workspace\Models\Workspace;
use App\Models\User;
it('lists links for authenticated workspace', function () {
$user = User::factory()->create();
$workspace = (new \App\Domains\Workspace\Actions\CreateWorkspace)->handle($user, ['name' => 'Test']);
Link::factory()->count(3)->create(['workspace_id' => $workspace->id]);
$token = $user->createToken('api-test')->plainTextToken;
$response = $this->withToken($token)
->getJson("/api/v1/workspaces/{$workspace->ulid}/links");
$response->assertOk()
->assertJsonCount(3, 'data');
});
it('creates link via API', function () {
$user = User::factory()->create();
$workspace = (new \App\Domains\Workspace\Actions\CreateWorkspace)->handle($user, ['name' => 'Test']);
$token = $user->createToken('api-test')->plainTextToken;
$response = $this->withToken($token)
->postJson("/api/v1/workspaces/{$workspace->ulid}/links", [
'target_url' => 'https://example.com',
]);
$response->assertCreated()
->assertJsonPath('data.target_url', 'https://example.com');
});