Add public API with API key middleware and webhooks

This commit is contained in:
Arya Dwi Putra
2025-12-03 09:10:40 +07:00
parent fe85d1b91d
commit c0713bbd41
7 changed files with 195 additions and 5 deletions

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Asset;
use Illuminate\Http\Request;
class AssetApiController extends Controller
{
public function index(Request $request)
{
$perPage = min($request->integer('per_page', 25), 100);
$assets = Asset::query()
->with(['status', 'class', 'category', 'assetLocation', 'department', 'personInCharge'])
->when($request->filled('status'), fn ($q) => $q->whereHas('status', fn ($qq) => $qq->where('code', $request->string('status'))))
->when($request->filled('location_id'), fn ($q) => $q->where('asset_location_id', $request->string('location_id')))
->when($request->filled('department_id'), fn ($q) => $q->where('department_id', $request->string('department_id')))
->when($request->filled('search'), fn ($q) => $q->where(function ($qq) use ($request) {
$s = $request->string('search');
$qq->where('code', 'like', "%{$s}%")->orWhere('name', 'like', "%{$s}%");
}))
->paginate($perPage);
return response()->json($assets);
}
public function show(Asset $asset)
{
return response()->json(
$asset->load([
'status', 'class', 'category', 'assetLocation', 'department',
'personInCharge', 'assetUser', 'warranty', 'photos',
])
);
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Asset;
use App\Services\Asset\AssetService;
use Illuminate\Http\Request;
class AssetTransactionApiController extends Controller
{
public function __construct(private readonly AssetService $assets)
{
}
public function storeMovement(Request $request, Asset $asset)
{
$data = $request->validate([
'to_location_id' => ['nullable', 'uuid', 'exists:asset_locations,id'],
'to_department_id' => ['nullable', 'uuid', 'exists:departments,id'],
'to_asset_user_id' => ['nullable', 'uuid', 'exists:asset_users,id'],
'notes' => ['nullable', 'string'],
'performed_at' => ['nullable', 'date'],
]);
$movement = $this->assets->move($asset, $data);
return response()->json(['message' => 'Movement recorded', 'data' => $movement], 201);
}
public function storeDisposal(Request $request, Asset $asset)
{
$data = $request->validate([
'reason' => ['required', 'string', 'max:255'],
'notes' => ['nullable', 'string'],
'disposed_at' => ['nullable', 'date'],
]);
$disposal = $this->assets->dispose($asset, $data);
return response()->json(['message' => 'Disposal recorded', 'data' => $disposal], 201);
}
public function storeAudit(Request $request, Asset $asset)
{
$data = $request->validate([
'status' => ['required', 'string', 'in:matched,missing,damaged'],
'notes' => ['nullable', 'string'],
'location_id' => ['nullable', 'uuid', 'exists:asset_locations,id'],
'audited_at' => ['nullable', 'date'],
]);
$audit = $this->assets->audit($asset, $data);
return response()->json(['message' => 'Audit recorded', 'data' => $audit], 201);
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Http\Middleware;
use App\Services\System\SystemSettingService;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class VerifyApiKey
{
public function __construct(private readonly SystemSettingService $settings)
{
}
public function handle(Request $request, Closure $next): Response
{
$expected = $this->settings->get('integration.api_key') ?? config('services.integration.api_key');
$provided = $request->header('X-Api-Key');
if (empty($expected) || $provided !== $expected) {
return response()->json(['message' => 'Unauthorized: invalid API key'], 401);
}
return $next($request);
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Services\Integration;
use App\Services\System\SystemSettingService;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class WebhookDispatcher
{
public function __construct(private readonly SystemSettingService $settings)
{
}
/**
* Kirim webhook ke endpoint eksternal untuk sinkronisasi ERP/CMMS/Helpdesk.
*/
public function send(string $event, array $data): void
{
$enabled = $this->settings->get('integration.webhook_enabled', false);
$url = $this->settings->get('integration.webhook_url');
if (!$enabled || empty($url)) {
return;
}
$secret = $this->settings->get('integration.webhook_secret');
$signature = $secret ? hash_hmac('sha256', json_encode($data), $secret) : null;
try {
Http::timeout(5)
->withHeaders(array_filter([
'X-Webhook-Event' => $event,
'X-Webhook-Signature' => $signature,
]))
->post($url, [
'event' => $event,
'data' => $data,
'sent_at' => now()->toIso8601String(),
]);
} catch (\Throwable $e) {
Log::warning('Webhook dispatch failed', ['event' => $event, 'message' => $e->getMessage()]);
}
}
}

View File

@@ -15,6 +15,7 @@ use Illuminate\Console\Scheduling\Schedule;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__ . '/../routes/web.php',
api: __DIR__ . '/../routes/api.php',
commands: __DIR__ . '/../routes/console.php',
health: '/up',
)
@@ -27,6 +28,7 @@ return Application::configure(basePath: dirname(__DIR__))
'session.timeout' => SessionTimeout::class,
'audit.request' => AuditRequest::class,
'db.ready' => EnsureDatabaseReady::class,
'api.key' => \App\Http\Middleware\VerifyApiKey::class,
]);
})
->withSchedule(function (Schedule $schedule) {

View File

@@ -15,8 +15,8 @@ $bodyClass = 'bg-white';
<ol class="text-muted mb-3">
<li>Buat database (mis. <code>asset_management_system</code>).</li>
<li>Isi kredensial di file <code>.env</code> (DB_HOST, DB_PORT, DB_DATABASE, DB_USERNAME, DB_PASSWORD).</li>
<li>Jalankan migrasi dan seeder: <code>php artisan migrate --seed</code>.</li>
<li>Setelah itu, jalankan wizard di <a href="{{ route('setup.index') }}">/setup</a> bila diperlukan.</li>
<li>Klik salah satu tombol di bawah untuk menjalankan migrasi (dengan/atau tanpa sample data).</li>
<li>Setelah migrasi, lanjutkan wizard di <a href="{{ route('setup.index') }}">/setup</a>.</li>
</ol>
@if($errors->any())
<div class="alert alert-danger">
@@ -25,9 +25,17 @@ $bodyClass = 'bg-white';
@endforeach
</div>
@endif
<div class="d-flex justify-content-between">
<a href="{{ url()->previous() }}" class="btn btn-outline-secondary">Kembali</a>
<a href="{{ route('setup.index') }}" class="btn btn-primary">Buka Setup</a>
<div class="d-flex flex-wrap gap-2">
<form method="POST" action="{{ route('setup.migrate') }}">
@csrf
<button type="submit" class="btn btn-primary">Migrate &amp; Seed Minimal (RBAC)</button>
</form>
<form method="POST" action="{{ route('setup.migrate') }}">
@csrf
<input type="hidden" name="with_sample" value="1">
<button type="submit" class="btn btn-success">Migrate + Sample Data (Full Seed)</button>
</form>
<a href="{{ url()->previous() }}" class="btn btn-outline-secondary ms-auto">Kembali</a>
</div>
</div>
</div>

14
routes/api.php Normal file
View File

@@ -0,0 +1,14 @@
<?php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\Api\AssetApiController;
use App\Http\Controllers\Api\AssetTransactionApiController;
Route::middleware(['api.key'])->prefix('v1')->group(function () {
Route::get('assets', [AssetApiController::class, 'index']);
Route::get('assets/{asset}', [AssetApiController::class, 'show']);
Route::post('assets/{asset}/movements', [AssetTransactionApiController::class, 'storeMovement']);
Route::post('assets/{asset}/disposals', [AssetTransactionApiController::class, 'storeDisposal']);
Route::post('assets/{asset}/audits', [AssetTransactionApiController::class, 'storeAudit']);
});