pietrocal-client.jsjavascript/pietrocal-client.js · 325 líneas
const IDEMPOTENT_POST_ENDPOINTS = new Set([
'/calendars.php',
'/calendar_subscriptions.php',
'/events.php',
'/tasks.php',
'/contacts.php',
'/bookings.php',
'/availability_links.php'
]);
export class PietroCalApiError extends Error {
constructor(message, status, responseData = null, responseHeaders = {}) {
super(message);
this.name = 'PietroCalApiError';
this.status = status;
this.responseData = responseData;
this.responseHeaders = responseHeaders;
}
}
export class PietroCalClient {
constructor({
token,
baseUrl = 'https://pietrocal.com/api/v1',
fetchImpl = globalThis.fetch,
timeoutMs = 30000,
maxTransportRetries = 1
} = {}) {
if (!String(token || '').startsWith('pc_live_')) {
throw new TypeError('El token debe comenzar con pc_live_.');
}
if (typeof fetchImpl !== 'function') {
throw new TypeError('Este entorno no dispone de fetch().');
}
if (!Number.isInteger(maxTransportRetries) || maxTransportRetries < 0 || maxTransportRetries > 5) {
throw new TypeError('maxTransportRetries debe estar entre 0 y 5.');
}
this.token = token;
this.baseUrl = String(baseUrl).replace(/\/+$/, '');
this.fetchImpl = fetchImpl;
this.timeoutMs = timeoutMs;
this.maxTransportRetries = maxTransportRetries;
}
async request(method, endpoint, {
query = {},
body,
signal,
idempotencyKey = null,
autoIdempotency = true,
maxTransportRetries = this.maxTransportRetries
} = {}) {
method = String(method).toUpperCase();
if (!['GET', 'POST', 'PATCH', 'PUT', 'DELETE'].includes(method)) {
throw new TypeError('Método HTTP no permitido.');
}
const normalizedEndpoint = `/${String(endpoint).replace(/^\/+/, '')}`;
if (/[\r\n]/.test(normalizedEndpoint)) {
throw new TypeError('Endpoint inválido.');
}
const url = new URL(`${this.baseUrl}${normalizedEndpoint}`);
Object.entries(query || {}).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
url.searchParams.set(key, String(value));
}
});
const resolvedKey = this.resolveIdempotencyKey(
method,
normalizedEndpoint,
idempotencyKey,
autoIdempotency
);
const retryLimit = method === 'POST' && resolvedKey
? clampInteger(maxTransportRetries, 0, 5)
: 0;
const encodedBody = body === undefined ? undefined : JSON.stringify(body);
let transportAttempts = 0;
while (true) {
transportAttempts += 1;
const controller = new AbortController();
let externalAbortHandler = null;
let timedOut = false;
const timeout = setTimeout(() => {
timedOut = true;
controller.abort();
}, this.timeoutMs);
if (signal) {
if (signal.aborted) {
clearTimeout(timeout);
throw signal.reason || new DOMException('Abortado.', 'AbortError');
}
externalAbortHandler = () => controller.abort(signal.reason);
signal.addEventListener('abort', externalAbortHandler, { once: true });
}
const headers = {
Accept: 'application/json',
Authorization: `Bearer ${this.token}`
};
const options = {
method,
headers,
signal: controller.signal,
credentials: 'omit'
};
if (encodedBody !== undefined) {
headers['Content-Type'] = 'application/json';
options.body = encodedBody;
}
if (resolvedKey) {
headers['Idempotency-Key'] = resolvedKey;
}
try {
const response = await this.fetchImpl(url, options);
const raw = await response.text();
let data = raw;
if (raw) {
try { data = JSON.parse(raw); } catch (_) {}
} else {
data = null;
}
const responseHeaders = {};
[
'content-type', 'x-request-id', 'x-ratelimit-limit',
'x-ratelimit-remaining', 'x-pietrocal-api-version',
'x-pagination-page', 'x-pagination-per-page',
'x-pagination-total', 'x-pagination-total-pages',
'retry-after', 'idempotency-key', 'idempotency-replayed'
].forEach(name => {
const value = response.headers.get(name);
if (value !== null) responseHeaders[name] = value;
});
if (!response.ok) {
const message = data?.error?.message || data?.message || 'La API devolvió un error.';
throw new PietroCalApiError(message, response.status, data, responseHeaders);
}
return {
status: response.status,
data,
headers: responseHeaders,
requestId: responseHeaders['x-request-id'] || null,
rateLimit: {
limit: numberOrNull(responseHeaders['x-ratelimit-limit']),
remaining: numberOrNull(responseHeaders['x-ratelimit-remaining'])
},
pagination: {
page: numberOrNull(responseHeaders['x-pagination-page']),
perPage: numberOrNull(responseHeaders['x-pagination-per-page']),
total: numberOrNull(responseHeaders['x-pagination-total']),
totalPages: numberOrNull(responseHeaders['x-pagination-total-pages'])
},
idempotency: {
key: responseHeaders['idempotency-key'] || resolvedKey || null,
replayed: booleanOrNull(responseHeaders['idempotency-replayed'])
},
transportAttempts
};
} catch (error) {
if (error instanceof PietroCalApiError) {
throw error;
}
const externallyAborted = Boolean(signal?.aborted);
const mayRetry = !externallyAborted
&& transportAttempts <= retryLimit
&& method === 'POST'
&& Boolean(resolvedKey);
if (!mayRetry) {
if (timedOut) {
throw new Error('Error de red: timeout de la solicitud.', { cause: error });
}
throw error;
}
await sleep(retryDelayMs(transportAttempts));
} finally {
clearTimeout(timeout);
if (signal && externalAbortHandler) {
signal.removeEventListener('abort', externalAbortHandler);
}
}
}
}
static generateIdempotencyKey() {
if (typeof globalThis.crypto?.randomUUID === 'function') {
return `sdk_${globalThis.crypto.randomUUID().replaceAll('-', '')}`;
}
if (typeof globalThis.crypto?.getRandomValues === 'function') {
const bytes = new Uint8Array(16);
globalThis.crypto.getRandomValues(bytes);
return `sdk_${Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('')}`;
}
return `sdk_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 18)}`;
}
profile() {
return this.request('GET', '/me_app.php');
}
calendars() {
return this.request('GET', '/calendars.php');
}
events({ page = 1, perPage = 50, ...filters } = {}) {
return this.request('GET', '/events.php', { query: { ...filters, page, perPage } });
}
tasks(filters = {}) {
return this.request('GET', '/tasks.php', { query: filters });
}
contacts({ page = 1, perPage = 50, ...filters } = {}) {
return this.request('GET', '/contacts.php', { query: { ...filters, page, perPage } });
}
bookings({ page = 1, perPage = 50, ...filters } = {}) {
return this.request('GET', '/bookings.php', { query: { ...filters, page, perPage } });
}
createCalendar(calendar, idempotencyKey = null) {
return this.create('/calendars.php', calendar, idempotencyKey);
}
createCalendarSubscription(subscription, idempotencyKey = null) {
return this.create('/calendar_subscriptions.php', subscription, idempotencyKey);
}
createEvent(event, idempotencyKey = null) {
return this.create('/events.php', event, idempotencyKey);
}
createTask(task, idempotencyKey = null) {
return this.create('/tasks.php', task, idempotencyKey);
}
createContact(contact, idempotencyKey = null) {
return this.create('/contacts.php', contact, idempotencyKey);
}
createBooking(booking, idempotencyKey = null) {
return this.create('/bookings.php', booking, idempotencyKey);
}
createAvailabilityLink(link, idempotencyKey = null) {
return this.create('/availability_links.php', link, idempotencyKey);
}
updateEvent(id, event) {
return this.request('PATCH', '/events.php', { query: { id }, body: event });
}
deleteEvent(id) {
return this.request('DELETE', '/events.php', { query: { id } });
}
create(endpoint, body, idempotencyKey) {
return this.request('POST', endpoint, { body, idempotencyKey });
}
resolveIdempotencyKey(method, endpoint, explicitKey, autoIdempotency) {
if (explicitKey !== null && explicitKey !== undefined) {
const key = String(explicitKey);
if (!validIdempotencyKey(key)) {
throw new TypeError('Idempotency-Key inválida.');
}
return key;
}
if (autoIdempotency && method === 'POST' && IDEMPOTENT_POST_ENDPOINTS.has(endpoint)) {
return PietroCalClient.generateIdempotencyKey();
}
return null;
}
}
function validIdempotencyKey(key) {
return /^[A-Za-z0-9][A-Za-z0-9._:-]{7,199}$/.test(key);
}
function numberOrNull(value) {
if (value === undefined || value === null || value === '') return null;
const number = Number(value);
return Number.isFinite(number) ? number : null;
}
function booleanOrNull(value) {
if (value === undefined || value === null || value === '') return null;
const normalized = String(value).trim().toLowerCase();
if (['true', '1', 'yes'].includes(normalized)) return true;
if (['false', '0', 'no'].includes(normalized)) return false;
return null;
}
function clampInteger(value, min, max) {
const number = Number(value);
if (!Number.isInteger(number)) {
throw new TypeError('maxTransportRetries debe ser un entero.');
}
return Math.max(min, Math.min(max, number));
}
function retryDelayMs(attempt) {
return Math.min(2000, 250 * (2 ** Math.max(0, attempt - 1)));
}
function sleep(milliseconds) {
return new Promise(resolve => setTimeout(resolve, milliseconds));
}