feat: add Snipe-IT asset synchronization

This commit is contained in:
2026-08-04 16:15:09 +01:00
parent 604180b735
commit 077015f17a
37 changed files with 1302 additions and 26 deletions

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Integrations\SnipeIt\Data;
use Carbon\CarbonImmutable;
final readonly class AssetData
{
public function __construct(
public int $snipeAssetId,
public string $assetTag,
public string $name,
public ?string $serial,
public ?int $modelId,
public ?string $modelName,
public ?int $categoryId,
public ?string $categoryName,
public ?string $manufacturer,
public ?int $statusId,
public ?string $statusName,
public ?int $locationId,
public ?string $locationName,
public bool $requestable,
public ?string $assignedType,
public ?int $assignedId,
public ?string $imageUrl,
public ?CarbonImmutable $sourceUpdatedAt,
) {}
/** @return array<string, mixed> */
public function toModelAttributes(): array
{
return [
'snipe_asset_id' => $this->snipeAssetId,
'asset_tag' => $this->assetTag,
'name' => $this->name,
'serial' => $this->serial,
'model_id' => $this->modelId,
'model_name' => $this->modelName,
'category_id' => $this->categoryId,
'category_name' => $this->categoryName,
'manufacturer' => $this->manufacturer,
'status_id' => $this->statusId,
'status_name' => $this->statusName,
'location_id' => $this->locationId,
'location_name' => $this->locationName,
'requestable' => $this->requestable,
'assigned_type' => $this->assignedType,
'assigned_id' => $this->assignedId,
'image_url' => $this->imageUrl,
'source_updated_at' => $this->sourceUpdatedAt,
];
}
public function contentHash(): string
{
$attributes = $this->toModelAttributes();
$attributes['source_updated_at'] = $this->sourceUpdatedAt?->toIso8601String();
return hash('sha256', json_encode($attributes, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE));
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Integrations\SnipeIt\Data;
final readonly class HardwarePage
{
/**
* @param list<array<string, mixed>> $rows
*/
public function __construct(
public int $total,
public int $offset,
public int $limit,
public array $rows,
) {}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Integrations\SnipeIt\Exceptions;
class SnipeItApiException extends SnipeItException
{
public function __construct(string $message, public readonly ?int $statusCode = null)
{
parent::__construct($message);
}
}

View File

@@ -0,0 +1,5 @@
<?php
namespace App\Integrations\SnipeIt\Exceptions;
class SnipeItConfigurationException extends SnipeItException {}

View File

@@ -0,0 +1,7 @@
<?php
namespace App\Integrations\SnipeIt\Exceptions;
use RuntimeException;
class SnipeItException extends RuntimeException {}

View File

@@ -0,0 +1,5 @@
<?php
namespace App\Integrations\SnipeIt\Exceptions;
class SnipeItMappingException extends SnipeItException {}

View File

@@ -0,0 +1,86 @@
<?php
namespace App\Integrations\SnipeIt;
use App\Integrations\SnipeIt\Data\AssetData;
use App\Integrations\SnipeIt\Exceptions\SnipeItMappingException;
use Carbon\CarbonImmutable;
use Illuminate\Support\Arr;
use Throwable;
final class SnipeItAssetMapper
{
/** @param array<string, mixed> $row */
public function map(array $row): AssetData
{
$id = $this->integer($row['id'] ?? null);
$assetTag = $this->text($row['asset_tag'] ?? null);
if ($id === null || $id < 1 || $assetTag === null) {
throw new SnipeItMappingException('A resposta do Snipe-IT contém um ativo sem ID ou etiqueta válida.');
}
$modelName = $this->text(Arr::get($row, 'model.name'));
return new AssetData(
snipeAssetId: $id,
assetTag: $assetTag,
name: $this->text($row['name'] ?? null) ?? $modelName ?? $assetTag,
serial: $this->text($row['serial'] ?? null),
modelId: $this->integer(Arr::get($row, 'model.id')),
modelName: $modelName,
categoryId: $this->integer(Arr::get($row, 'category.id')),
categoryName: $this->text(Arr::get($row, 'category.name')),
manufacturer: $this->text(Arr::get($row, 'manufacturer.name')),
statusId: $this->integer(Arr::get($row, 'status_label.id')),
statusName: $this->text(Arr::get($row, 'status_label.name')),
locationId: $this->integer(Arr::get($row, 'rtd_location.id') ?? Arr::get($row, 'location.id')),
locationName: $this->text(Arr::get($row, 'rtd_location.name') ?? Arr::get($row, 'location.name')),
requestable: $this->boolean($row['requestable'] ?? false),
assignedType: $this->text(Arr::get($row, 'assigned_to.type')),
assignedId: $this->integer(Arr::get($row, 'assigned_to.id')),
imageUrl: $this->text($row['image'] ?? $row['image_url'] ?? null),
sourceUpdatedAt: $this->date(Arr::get($row, 'updated_at.datetime') ?? Arr::get($row, 'updated_at.date') ?? $row['updated_at'] ?? null),
);
}
private function text(mixed $value): ?string
{
if (! is_scalar($value)) {
return null;
}
$decoded = trim(html_entity_decode((string) $value, ENT_QUOTES | ENT_HTML5, 'UTF-8'));
return $decoded === '' ? null : $decoded;
}
private function integer(mixed $value): ?int
{
return is_numeric($value) ? (int) $value : null;
}
private function boolean(mixed $value): bool
{
if (is_bool($value)) {
return $value;
}
return in_array(strtolower((string) $value), ['1', 'true', 'yes', 'sim'], true);
}
private function date(mixed $value): ?CarbonImmutable
{
$text = $this->text($value);
if ($text === null) {
return null;
}
try {
return CarbonImmutable::parse($text);
} catch (Throwable) {
return null;
}
}
}

View File

@@ -0,0 +1,142 @@
<?php
namespace App\Integrations\SnipeIt;
use App\Integrations\SnipeIt\Data\HardwarePage;
use App\Integrations\SnipeIt\Exceptions\SnipeItApiException;
use App\Integrations\SnipeIt\Exceptions\SnipeItConfigurationException;
use Generator;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Throwable;
final class SnipeItClient
{
public function isConfigured(): bool
{
return filled(config('services.snipe_it.url')) && filled(config('services.snipe_it.read_token'));
}
public function configuredUrl(): ?string
{
$url = trim((string) config('services.snipe_it.url'));
return $url === '' ? null : rtrim($url, '/');
}
public function listHardwarePage(int $offset = 0, ?int $limit = null): HardwarePage
{
$pageSize = $limit ?? (int) config('services.snipe_it.page_size', 100);
$pageSize = max(1, min(500, $pageSize));
$response = $this->request()->get('/hardware', [
'limit' => $pageSize,
'offset' => max(0, $offset),
'sort' => 'id',
'order' => 'asc',
]);
if (! $response->successful()) {
throw new SnipeItApiException(
"O Snipe-IT respondeu com HTTP {$response->status()} ao consultar equipamentos.",
$response->status(),
);
}
$payload = $response->json();
if (! is_array($payload)) {
throw new SnipeItApiException('O Snipe-IT devolveu uma resposta que não é JSON válido.');
}
if (($payload['status'] ?? null) === 'error') {
throw new SnipeItApiException('O Snipe-IT recusou a consulta de equipamentos apesar de responder com HTTP 200.');
}
$total = $payload['total'] ?? null;
$rows = $payload['rows'] ?? null;
if (! is_numeric($total) || ! is_array($rows)) {
throw new SnipeItApiException('A resposta do Snipe-IT não contém os campos de paginação esperados.');
}
/** @var list<array<string, mixed>> $normalizedRows */
$normalizedRows = [];
foreach (array_values($rows) as $row) {
if (! is_array($row)) {
throw new SnipeItApiException('A resposta do Snipe-IT contém um equipamento num formato inválido.');
}
/** @var array<string, mixed> $row */
$normalizedRows[] = $row;
}
return new HardwarePage((int) $total, max(0, $offset), $pageSize, $normalizedRows);
}
/** @return Generator<int, HardwarePage> */
public function hardwarePages(): Generator
{
$offset = 0;
do {
$page = $this->listHardwarePage($offset);
yield $page;
$received = count($page->rows);
$offset += $received;
} while ($received > 0 && $offset < $page->total);
}
private function request(): PendingRequest
{
if (! $this->isConfigured()) {
throw new SnipeItConfigurationException('A URL e o token de leitura do Snipe-IT ainda não estão configurados.');
}
$retries = max(1, (int) config('services.snipe_it.retries', 3));
$retryDelay = max(0, (int) config('services.snipe_it.retry_delay_ms', 250));
return Http::baseUrl($this->apiBaseUrl())
->acceptJson()
->asJson()
->withToken((string) config('services.snipe_it.read_token'))
->withUserAgent(Str::slug((string) config('app.name')).'/inventory-sync')
->connectTimeout(min(5, max(1, (int) config('services.snipe_it.timeout', 10))))
->timeout(max(1, (int) config('services.snipe_it.timeout', 10)))
->retry(
$retries,
$retryDelay,
fn (Throwable $exception): bool => $this->shouldRetry($exception),
throw: false,
);
}
private function apiBaseUrl(): string
{
$baseUrl = $this->configuredUrl();
if ($baseUrl === null || ! filter_var($baseUrl, FILTER_VALIDATE_URL)) {
throw new SnipeItConfigurationException('A URL configurada para o Snipe-IT não é válida.');
}
return str_ends_with($baseUrl, '/api/v1') ? $baseUrl : $baseUrl.'/api/v1';
}
private function shouldRetry(Throwable $exception): bool
{
if ($exception instanceof ConnectionException) {
return true;
}
if (! $exception instanceof RequestException || $exception->response === null) {
return false;
}
return $exception->response->status() === 429 || $exception->response->serverError();
}
}