321 lines
13 KiB
PHP
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);
|
|
}
|
|
}
|