*/ public function find(string $module, string $id): array { $this->module($module); $this->identifier($id); return $this->request('GET', '/module/'.$module.'/'.$id, [], 'find'); } /** * @param array $equals Exact-match filters; no arbitrary URLs. * @return array */ public function search(string $module, #[SensitiveParameter] array $equals, int $page = 1, int $size = 20): array { $this->module($module); if ($page < 1 || $size < 1 || $size > 100) { throw new SuiteCrmException('invalid_pagination'); } $filter = ['operator' => 'and']; foreach ($equals as $field => $value) { $this->identifier($field); if ($field === 'operator') { throw new SuiteCrmException('invalid_filter'); } $filter[$field] = ['eq' => $value]; } return $this->request('GET', '/module/'.$module, ['filter' => $filter, 'page' => ['number' => $page, 'size' => $size]], 'search'); } /** * @param array $attributes * @return array */ public function create(string $module, #[SensitiveParameter] array $attributes): array { $this->module($module); return $this->request('POST', '/module', ['data' => ['type' => $module, 'attributes' => $attributes]], 'create'); } /** * @param array $attributes * @return array */ public function update(string $module, string $id, #[SensitiveParameter] array $attributes): array { $this->module($module); $this->identifier($id); return $this->request('PATCH', '/module', ['data' => ['type' => $module, 'id' => $id, 'attributes' => $attributes]], 'update'); } /** @return array */ public function relate(string $module, string $id, string $linkField, string $relatedModule, string $relatedId): array { $this->module($module); $this->module($relatedModule); foreach ([$id, $linkField, $relatedId] as $identifier) { $this->identifier($identifier); } return $this->request('POST', '/module/'.$module.'/'.$id.'/relationships/'.$linkField, ['data' => ['type' => $relatedModule, 'id' => $relatedId]], 'relate'); } /** @return array */ public function fields(string $module): array { $this->module($module); return $this->request('GET', '/meta/fields/'.$module, [], 'fields'); } private function module(string $module): void { $this->identifier($module); if (! in_array($module, config()->array('suitecrm.modules'), true)) { throw new SuiteCrmException('module_not_allowed'); } } private function identifier(string $value): void { if (! preg_match('/\A[A-Za-z0-9_][A-Za-z0-9_-]{0,99}\z/', $value)) { throw new SuiteCrmException('invalid_identifier'); } } private function baseUrl(): string { if (! config('suitecrm.enabled')) { throw new SuiteCrmException('disabled'); } $url = config()->string('suitecrm.base_url'); $parts = parse_url($url); if (! $parts || ($parts['scheme'] ?? '') !== 'https' || empty($parts['host']) || isset($parts['user']) || isset($parts['pass']) || isset($parts['query']) || isset($parts['fragment']) || ! filter_var($url, FILTER_VALIDATE_URL) || preg_match('/[\s\\\\]/', $url)) { throw new SuiteCrmException('invalid_base_url'); } if (config()->string('suitecrm.client_id') === '' || config()->string('suitecrm.client_secret') === '') { throw new SuiteCrmException('missing_credentials'); } return rtrim($url, '/'); } private function cacheKey(string $base): string { // Rotation separates both token and breaker state without exposing the secret. return 'suitecrm:'.hash('sha256', $base.'|'.config()->string('suitecrm.client_id').'|'.config()->string('suitecrm.client_secret')); } private function token(string $base, string $key): string { $cache = Cache::store(config()->string('suitecrm.cache_store')); try { return $this->lock($key.':token-lock', 20)->block(3, function () use ($cache, $base, $key): string { $encrypted = $cache->get($key.':token'); if (is_string($encrypted)) { try { return Crypt::decryptString($encrypted); } catch (DecryptException) { $cache->forget($key.':token'); } } $response = $this->send('POST', $base.'/Api/access_token', [ 'grant_type' => 'client_credentials', 'client_id' => config()->string('suitecrm.client_id'), 'client_secret' => config()->string('suitecrm.client_secret'), ], null, 'authenticate'); if (! $response->successful()) { throw $this->failure($response, false); } $data = $response->json(); if (! is_array($data) || ! is_string($data['access_token'] ?? null) || $data['access_token'] === '' || ! is_numeric($data['expires_in'] ?? null) || (int) $data['expires_in'] < 2 || ! is_string($data['token_type'] ?? null) || strtolower($data['token_type']) !== 'bearer') { throw new SuiteCrmException('invalid_token_response'); } $seconds = (int) $data['expires_in']; $cache->put($key.':token', Crypt::encryptString($data['access_token']), max(1, $seconds - min(60, (int) ceil($seconds / 10)))); return $data['access_token']; }); } catch (LockTimeoutException) { throw new SuiteCrmException('authentication_busy', retryable: true); } } private function lock(string $name, int $seconds): Lock { $store = Cache::store(config()->string('suitecrm.cache_store'))->getStore(); if (! $store instanceof LockProvider) { throw new SuiteCrmException('cache_locks_required'); } return $store->lock($name, $seconds); } private function invalidateToken(string $key, #[SensitiveParameter] string $rejected): void { $cache = Cache::store(config()->string('suitecrm.cache_store')); try { $this->lock($key.':token-lock', 20)->block(3, function () use ($cache, $key, $rejected): void { $encrypted = $cache->get($key.':token'); if (! is_string($encrypted)) { return; } try { // A concurrent request may already have renewed this token. if (hash_equals(Crypt::decryptString($encrypted), $rejected)) { $cache->forget($key.':token'); } } catch (DecryptException) { $cache->forget($key.':token'); } }); } catch (LockTimeoutException) { throw new SuiteCrmException('authentication_busy', retryable: true); } } /** * @param array $data * @return array */ private function request(string $method, string $path, #[SensitiveParameter] array $data, string $operation): array { $base = $this->baseUrl(); $key = $this->cacheKey($base); $cache = Cache::store(config()->string('suitecrm.cache_store')); if ($cache->has($key.':open')) { throw new SuiteCrmException('circuit_open', retryable: true); } $read = $method === 'GET'; $limit = $read ? max(0, min(3, config()->integer('suitecrm.read_retries'))) : 0; $renewed = false; $attempt = 0; try { while (true) { try { $token = $this->token($base, $key); $response = $this->send($method, $base.'/Api/V8'.$path, $data, $token, $operation); if ($response->status() === 401 && ! $renewed) { $this->invalidateToken($key, $token); $renewed = true; continue; } if (! $response->successful()) { throw $this->failure($response, ! $read); } $result = $response->json(); if ($response->status() === 204) { $result = []; } if (! is_array($result) || ! empty($result['errors'])) { throw new SuiteCrmException('invalid_response', outcomeUnknown: ! $read); } $cache->forget($key.':failures'); return $result; } catch (SuiteCrmException $e) { // Never replay writes after ambiguous transport/server failures. if (! $read || ! $e->retryable || $attempt >= $limit || ($e->retryAfter ?? 0) > 5) { throw $e; } $delay = max(($e->retryAfter ?? 0) * 1000, min(4000, max(0, config()->integer('suitecrm.retry_delay_ms')) * (2 ** $attempt))); if ($delay > 0) { Sleep::usleep(($delay + random_int(0, 100)) * 1000); } $attempt++; } } } catch (SuiteCrmException $e) { if ($e->retryable || $e->reason === 'invalid_response') { try { $this->lock($key.':circuit-lock', 5)->block(1, function () use ($cache, $key): void { $failures = (int) $cache->get($key.':failures', 0) + 1; $cooldown = max(5, config()->integer('suitecrm.circuit_cooldown')); $cache->put($key.':failures', $failures, $cooldown); if ($failures >= max(1, config()->integer('suitecrm.circuit_threshold'))) { $cache->put($key.':open', true, $cooldown); } }); } catch (LockTimeoutException) { // Preserve the sanitised operation failure if bookkeeping is busy. } } throw $e; } } /** @param array $data */ private function send(string $method, string $url, #[SensitiveParameter] array $data, #[SensitiveParameter] ?string $token, string $operation): Response { $started = hrtime(true); $status = null; try { $request = Http::connectTimeout(max(1, min(5, config()->integer('suitecrm.connect_timeout')))) ->timeout(max(1, min(10, config()->integer('suitecrm.timeout')))) ->withoutRedirecting()->withOptions(['verify' => true])->asJson() ->accept($token === null ? 'application/json' : 'application/vnd.api+json'); if ($token !== null) { $request = $request->withToken($token)->contentType('application/vnd.api+json'); } $response = $request->send($method, $url, [$method === 'GET' ? 'query' : 'json' => $data]); $status = $response->status(); return $response; } catch (ConnectionException) { throw new SuiteCrmException('transport_failure', retryable: true, outcomeUnknown: $token !== null && $method !== 'GET'); } finally { // Bounded dimensions; no URL, identifiers, attributes, bodies or headers. Log::info('suitecrm.http', ['operation' => $operation, 'status' => $status, 'duration_ms' => (int) ((hrtime(true) - $started) / 1_000_000)]); } } private function failure(Response $response, bool $write): SuiteCrmException { $status = $response->status(); $retryable = $status === 429 || $status >= 500; $retryAfter = null; $header = $response->header('Retry-After'); if ($header !== '') { $timestamp = strtotime($header); $retryAfter = ctype_digit($header) ? min(86400, (int) $header) : ($timestamp === false ? null : max(0, min(86400, $timestamp - time()))); } return new SuiteCrmException(match ($status) { 401 => 'unauthenticated', 403 => 'forbidden', 404 => 'not_found', 409 => 'conflict', 422 => 'validation_failed', 429 => 'rate_limited', default => $status >= 500 ? 'unavailable' : 'unexpected_status', }, $status, $retryable, $write && $status >= 500, $retryAfter); } }