PietroCalClient.phpphp/PietroCalClient.php · 425 líneas
<?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;
}
}