*/ private const IDEMPOTENT_POST_ENDPOINTS = [ '/calendars.php', '/calendar_subscriptions.php', '/events.php', '/tasks.php', '/contacts.php', '/bookings.php', '/availability_links.php', ]; public function __construct( private readonly string $token, private readonly string $baseUrl = self::DEFAULT_BASE_URL, private readonly int $timeoutSeconds = 30, private readonly int $maxTransportRetries = 1 ) { if (!str_starts_with($token, 'pc_live_')) { throw new RuntimeException('El token debe comenzar con pc_live_.'); } if ($timeoutSeconds < 1) { throw new RuntimeException('El timeout debe ser mayor que cero.'); } if ($maxTransportRetries < 0 || $maxTransportRetries > 5) { throw new RuntimeException('maxTransportRetries debe estar entre 0 y 5.'); } } /** * @param array $query * @param array{ * idempotencyKey?:string|null, * autoIdempotency?:bool, * maxTransportRetries?:int * } $options * @return array */ public function request( string $method, string $endpoint, array $query = [], mixed $body = null, array $options = [] ): array { $method = strtoupper($method); if (!in_array($method, ['GET', 'POST', 'PATCH', 'PUT', 'DELETE'], true)) { throw new RuntimeException('Método HTTP no permitido.'); } $endpoint = '/' . ltrim($endpoint, '/'); if (str_contains($endpoint, "\r") || str_contains($endpoint, "\n")) { throw new RuntimeException('Endpoint inválido.'); } $url = rtrim($this->baseUrl, '/') . $endpoint; if ($query !== []) { $url .= '?' . http_build_query($query, '', '&', PHP_QUERY_RFC3986); } $encodedBody = null; if ($body !== null) { try { $encodedBody = json_encode( $body, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ); } catch (JsonException $exception) { throw new RuntimeException('El cuerpo no se pudo convertir a JSON.', 0, $exception); } } $idempotencyKey = $this->resolveIdempotencyKey( $method, $endpoint, $options ); $transportRetries = $this->resolveTransportRetries( $method, $idempotencyKey, $options ); $attempts = 0; while (true) { $attempts++; $curl = curl_init($url); if ($curl === false) { throw new RuntimeException('No se pudo inicializar cURL.'); } $headers = [ 'Accept: application/json', 'Authorization: Bearer ' . $this->token, 'User-Agent: PietroCal-PHP-SDK/1.1', ]; if ($encodedBody !== null) { $headers[] = 'Content-Type: application/json'; } if ($idempotencyKey !== null) { $headers[] = 'Idempotency-Key: ' . $idempotencyKey; } curl_setopt_array($curl, [ CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $headers, CURLOPT_RETURNTRANSFER => true, CURLOPT_HEADER => true, CURLOPT_CONNECTTIMEOUT => min(10, $this->timeoutSeconds), CURLOPT_TIMEOUT => $this->timeoutSeconds, CURLOPT_FOLLOWLOCATION => false, ]); if ($encodedBody !== null) { curl_setopt($curl, CURLOPT_POSTFIELDS, $encodedBody); } $raw = curl_exec($curl); if ($raw === false) { $message = curl_error($curl); curl_close($curl); if ($attempts <= $transportRetries) { usleep($this->retryDelayMicroseconds($attempts)); continue; } throw new RuntimeException('Error de red: ' . $message); } $status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE); $headerSize = (int) curl_getinfo($curl, CURLINFO_HEADER_SIZE); curl_close($curl); $rawHeaders = substr($raw, 0, $headerSize); $rawBody = substr($raw, $headerSize); $responseHeaders = $this->parseHeaders($rawHeaders); $data = null; if (trim($rawBody) !== '') { try { $data = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR); } catch (JsonException) { $data = $rawBody; } } if ($status < 200 || $status >= 300) { $message = is_array($data) ? (string) ($data['error']['message'] ?? $data['message'] ?? 'La API devolvió un error.') : 'La API devolvió un error.'; throw new PietroCalApiException($message, $status, $data, $responseHeaders); } return [ 'status' => $status, 'data' => $data, 'headers' => $responseHeaders, 'requestId' => $responseHeaders['x-request-id'] ?? null, 'rateLimit' => [ 'limit' => isset($responseHeaders['x-ratelimit-limit']) ? (int) $responseHeaders['x-ratelimit-limit'] : null, 'remaining' => isset($responseHeaders['x-ratelimit-remaining']) ? (int) $responseHeaders['x-ratelimit-remaining'] : null, ], 'pagination' => [ 'page' => isset($responseHeaders['x-pagination-page']) ? (int) $responseHeaders['x-pagination-page'] : null, 'perPage' => isset($responseHeaders['x-pagination-per-page']) ? (int) $responseHeaders['x-pagination-per-page'] : null, 'total' => isset($responseHeaders['x-pagination-total']) ? (int) $responseHeaders['x-pagination-total'] : null, 'totalPages' => isset($responseHeaders['x-pagination-total-pages']) ? (int) $responseHeaders['x-pagination-total-pages'] : null, ], 'idempotency' => [ 'key' => $responseHeaders['idempotency-key'] ?? $idempotencyKey, 'replayed' => $this->headerBoolean( $responseHeaders['idempotency-replayed'] ?? null ), ], 'transportAttempts' => $attempts, ]; } } public static function generateIdempotencyKey(): string { return 'sdk_' . bin2hex(random_bytes(16)); } public function profile(): array { return $this->request('GET', '/me_app.php'); } public function calendars(): array { return $this->request('GET', '/calendars.php'); } public function events(int $page = 1, int $perPage = 50, array $filters = []): array { return $this->request('GET', '/events.php', [ ...$filters, 'page' => $page, 'perPage' => $perPage, ]); } public function tasks(array $filters = []): array { return $this->request('GET', '/tasks.php', $filters); } public function contacts(int $page = 1, int $perPage = 50, array $filters = []): array { return $this->request('GET', '/contacts.php', [ ...$filters, 'page' => $page, 'perPage' => $perPage, ]); } public function bookings(int $page = 1, int $perPage = 50, array $filters = []): array { return $this->request('GET', '/bookings.php', [ ...$filters, 'page' => $page, 'perPage' => $perPage, ]); } public function createCalendar(array $calendar, ?string $idempotencyKey = null): array { return $this->create('/calendars.php', $calendar, $idempotencyKey); } public function createCalendarSubscription( array $subscription, ?string $idempotencyKey = null ): array { return $this->create( '/calendar_subscriptions.php', $subscription, $idempotencyKey ); } public function createEvent(array $event, ?string $idempotencyKey = null): array { return $this->create('/events.php', $event, $idempotencyKey); } public function createTask(array $task, ?string $idempotencyKey = null): array { return $this->create('/tasks.php', $task, $idempotencyKey); } public function createContact(array $contact, ?string $idempotencyKey = null): array { return $this->create('/contacts.php', $contact, $idempotencyKey); } public function createBooking(array $booking, ?string $idempotencyKey = null): array { return $this->create('/bookings.php', $booking, $idempotencyKey); } public function createAvailabilityLink( array $link, ?string $idempotencyKey = null ): array { return $this->create('/availability_links.php', $link, $idempotencyKey); } public function updateEvent(string $id, array $event): array { return $this->request('PATCH', '/events.php', ['id' => $id], $event); } public function deleteEvent(string $id): array { return $this->request('DELETE', '/events.php', ['id' => $id]); } private function create( string $endpoint, array $body, ?string $idempotencyKey ): array { return $this->request( 'POST', $endpoint, [], $body, ['idempotencyKey' => $idempotencyKey] ); } /** @param array $options */ private function resolveIdempotencyKey( string $method, string $endpoint, array $options ): ?string { $explicit = $options['idempotencyKey'] ?? null; if ($explicit !== null) { if (!is_string($explicit) || !$this->validIdempotencyKey($explicit)) { throw new RuntimeException('Idempotency-Key inválida.'); } return $explicit; } $auto = array_key_exists('autoIdempotency', $options) ? (bool) $options['autoIdempotency'] : true; if ( $auto && $method === 'POST' && in_array($endpoint, self::IDEMPOTENT_POST_ENDPOINTS, true) ) { return self::generateIdempotencyKey(); } return null; } /** @param array $options */ private function resolveTransportRetries( string $method, ?string $idempotencyKey, array $options ): int { if ($method !== 'POST' || $idempotencyKey === null) { return 0; } $value = array_key_exists('maxTransportRetries', $options) ? (int) $options['maxTransportRetries'] : $this->maxTransportRetries; return max(0, min(5, $value)); } private function validIdempotencyKey(string $key): bool { $length = strlen($key); return $length >= 8 && $length <= 200 && preg_match('/^[A-Za-z0-9][A-Za-z0-9._:-]{7,199}$/D', $key) === 1; } private function retryDelayMicroseconds(int $attempt): int { return min(2000000, 250000 * (2 ** max(0, $attempt - 1))); } private function headerBoolean(?string $value): ?bool { if ($value === null) { return null; } return match (strtolower(trim($value))) { 'true', '1', 'yes' => true, 'false', '0', 'no' => false, default => null, }; } private function parseHeaders(string $rawHeaders): array { $headers = []; foreach (preg_split('/\r\n|\r|\n/', trim($rawHeaders)) ?: [] as $line) { if (!str_contains($line, ':')) { continue; } [$name, $value] = explode(':', $line, 2); $headers[strtolower(trim($name))] = trim($value); } return $headers; } }