SDKs oficiales · PietroCal API v1
PietroCal API v1 · PHP

Cliente de API para PHP

Cliente Bearer para PietroCal API v1 con Request ID, paginación, idempotencia automática y reintento seguro de transporte. Revisá los requisitos, copiá el archivo completo o descargalo para incorporarlo a tu proyecto.

PietroCalClient.phpphp/PietroCalClient.php · 425 líneas
Descargar
<?php

declare(strict_types=1);

namespace PietroCal\Sdk;

use JsonException;
use RuntimeException;


/*
 * 10Q00.4.1: una solicitud HTTP directa debe mostrar el visor seguro en lugar
 * de ejecutar este archivo. Los usos por CLI o mediante require/include no se
 * modifican.
 */
if (
    PHP_SAPI !== 'cli'
    && isset($_SERVER['SCRIPT_FILENAME'])
    && realpath((string)$_SERVER['SCRIPT_FILENAME']) === __FILE__
) {
    header('Cache-Control: no-store');
    header('Location: /api/sdk/view.php?file=php%2FPietroCalClient.php', true, 302);
    exit;
}

final class PietroCalApiException extends RuntimeException
{
    public function __construct(
        string $message,
        public readonly int $status,
        public readonly mixed $responseData = null,
        public readonly array $responseHeaders = []
    ) {
        parent::__construct($message, $status);
    }
}

final class PietroCalClient
{
    private const DEFAULT_BASE_URL = 'https://pietrocal.com/api/v1';

    /** @var list<string> */
    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<string,mixed> $query
     * @param array{
     *   idempotencyKey?:string|null,
     *   autoIdempotency?:bool,
     *   maxTransportRetries?:int
     * } $options
     * @return array<string,mixed>
     */
    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<string,mixed> $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<string,mixed> $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;
    }
}