SDKs oficiales · PietroCal API v1
PietroCal API v1 · JavaScript

Cliente de API para JavaScript

ES Module para Node.js o aplicaciones que utilicen OAuth. Un Personal Access Token no debe publicarse en el navegador. Revisá los requisitos, copiá el archivo completo o descargalo para incorporarlo a tu proyecto.

pietrocal-client.jsjavascript/pietrocal-client.js · 325 líneas
Descargar
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));
}