readRequest()->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> $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 $row */ $normalizedRows[] = $row; } return new HardwarePage((int) $total, max(0, $offset), $pageSize, $normalizedRows); } /** @return Generator */ 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); } /** @return array */ public function checkoutHardware( int $hardwareId, int $statusId, int $assignedUserId, string $expectedCheckin, ?string $note = null, ): array { $this->assertPositiveIdentifiers($hardwareId, $statusId, $assignedUserId); $payload = [ 'status_id' => $statusId, 'checkout_to_type' => 'user', 'assigned_user' => $assignedUserId, 'expected_checkin' => $expectedCheckin, ]; if (filled($note)) { $payload['note'] = $note; } $response = $this->operationalRequest()->post("/hardware/{$hardwareId}/checkout", $payload); return $this->operationPayload($response, 'entrega'); } /** @return array */ public function checkinHardware( int $hardwareId, int $statusId, ?int $locationId = null, ?string $note = null, ): array { $this->assertPositiveIdentifiers($hardwareId, $statusId); if ($locationId !== null && $locationId < 1) { throw new SnipeItConfigurationException('O identificador da localização do Snipe-IT tem de ser positivo.'); } $payload = ['status_id' => $statusId]; if ($locationId !== null) { $payload['location_id'] = $locationId; } if (filled($note)) { $payload['note'] = $note; } $response = $this->operationalRequest()->post("/hardware/{$hardwareId}/checkin", $payload); return $this->operationPayload($response, 'devolução'); } private function readRequest(): 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 operationalRequest(): PendingRequest { if (! $this->isOperationallyConfigured()) { throw new SnipeItConfigurationException('A URL e o token operacional do Snipe-IT ainda não estão configurados.'); } return Http::baseUrl($this->apiBaseUrl()) ->acceptJson() ->asJson() ->withToken((string) config('services.snipe_it.operational_token')) ->withUserAgent(Str::slug((string) config('app.name')).'/inventory-operations') ->connectTimeout(min(5, max(1, (int) config('services.snipe_it.timeout', 10)))) ->timeout(max(1, (int) config('services.snipe_it.timeout', 10))); } /** @return array */ private function operationPayload(Response $response, string $operation): array { if (! $response->successful()) { throw new SnipeItApiException( "O Snipe-IT respondeu com HTTP {$response->status()} durante a {$operation}.", $response->status(), ); } $payload = $response->json(); if (! is_array($payload)) { throw new SnipeItApiException("O Snipe-IT devolveu uma resposta inválida durante a {$operation}."); } if (($payload['status'] ?? null) !== 'success') { throw new SnipeItApiException("O Snipe-IT recusou a {$operation} apesar de responder com HTTP 200."); } /** @var array $payload */ return $payload; } private function assertPositiveIdentifiers(int ...$identifiers): void { foreach ($identifiers as $identifier) { if ($identifier < 1) { throw new SnipeItConfigurationException('Os identificadores do Snipe-IT têm de ser positivos.'); } } } 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(); } }