Files
Investidor-V2/app/Integrations/SuiteCrm/SuiteCrmClient.php
T
nala 250ac35fd1
tests / ci (push) Canceled after 0s
first commit
2026-09-28 10:01:09 +02:00

321 lines
13 KiB
PHP

<?php
namespace App\Integrations\SuiteCrm;
use Illuminate\Contracts\Cache\Lock;
use Illuminate\Contracts\Cache\LockProvider;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Sleep;
use SensitiveParameter;
final class SuiteCrmClient
{
/** @return array<string, mixed> */
public function find(string $module, string $id): array
{
$this->module($module);
$this->identifier($id);
return $this->request('GET', '/module/'.$module.'/'.$id, [], 'find');
}
/**
* @param array<string, string> $equals Exact-match filters; no arbitrary URLs.
* @return array<string, mixed>
*/
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<string, mixed> $attributes
* @return array<string, mixed>
*/
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<string, mixed> $attributes
* @return array<string, mixed>
*/
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<string, mixed> */
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<string, mixed> */
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<string, mixed> $data
* @return array<string, mixed>
*/
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<string, mixed> $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);
}
}