Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/calendly

src/client.ts

233 lines · 7.2 KB · TypeScript
import { calendlyFetch, resolveCalendlyAuth, type ResolvedCalendlyAuth } from './auth.ts'
import type { CalendlyAuthInput, CalendlyPageInfo, JsonRecord } from './types.ts'
import { requireString } from './types.ts'
import {
	CALENDLY_API_BASE_URL,
	CALENDLY_API_HOST,
	inferOperationFromPath,
	isMutatingMethod,
	nextStepForScope,
	scopeForOperation,
	type CalendlyOperation,
} from './setup.ts'

export class CalendlyApiError extends Error {
	readonly status: number
	readonly operation: CalendlyOperation
	readonly details: unknown
	readonly missingScope: string | null

	constructor(
		message: string,
		options: {
			status: number
			operation: CalendlyOperation
			details: unknown
			missingScope: string | null
		},
	) {
		super(message)
		this.name = 'CalendlyApiError'
		this.status = options.status
		this.operation = options.operation
		this.details = options.details
		this.missingScope = options.missingScope
	}
}

export type CalendlyRequestInput = CalendlyAuthInput & {
	path: string
	method?: string
	query?: JsonRecord
	body?: JsonRecord
	operation?: CalendlyOperation
}

export type CalendlyResponse<T> = {
	data: T
	pageInfo: CalendlyPageInfo
	status: number
}

export function uuidFromUri(value: string): string {
	const trimmed = requireString(value, 'uri')
	const parts = trimmed.replace(/\/+$/, '').split('/')
	const last = parts[parts.length - 1]
	if (!last) throw new Error('Calendly URI is missing a uuid segment.')
	return last
}

export function calendlyResourceUri(kind: string, idOrUri: string): string {
	const trimmed = requireString(idOrUri, kind)
	if (trimmed.startsWith('https://')) {
		const url = new URL(trimmed)
		if (url.host !== CALENDLY_API_HOST) {
			throw new Error(`Calendly resource URIs must use host ${CALENDLY_API_HOST}. Got ${url.host}.`)
		}
		return `${url.origin}${url.pathname}`.replace(/\/+$/, '')
	}
	return `${CALENDLY_API_BASE_URL}/${kind}/${trimmed}`
}

export function normalizeCalendlyPath(path: string): string {
	const trimmed = requireString(path, 'path')
	if (trimmed.startsWith('https://')) {
		const url = new URL(trimmed)
		if (url.host !== CALENDLY_API_HOST) {
			throw new Error(`Calendly requests must use host ${CALENDLY_API_HOST}. Got ${url.host}.`)
		}
		return `${url.pathname}${url.search}`
	}
	return trimmed.startsWith('/') ? trimmed : `/${trimmed}`
}

export function calendlyUrl(path: string, query?: JsonRecord): string {
	const normalized = normalizeCalendlyPath(path)
	const url = new URL(normalized, CALENDLY_API_BASE_URL)
	if (query) {
		for (const [key, value] of Object.entries(query)) {
			if (value === undefined || value === null) continue
			if (typeof value === 'boolean' || typeof value === 'number') {
				url.searchParams.set(key, String(value))
				continue
			}
			if (typeof value === 'string') {
				url.searchParams.set(key, value)
				continue
			}
			throw new Error(`query.${key} must be a string, number, or boolean.`)
		}
	}
	return url.toString()
}

function calendlyMessage(body: unknown, fallback: string): string {
	if (!body || typeof body !== 'object') return fallback
	const record = body as { title?: unknown; message?: unknown; details?: unknown }
	const title = typeof record.title === 'string' ? record.title : ''
	const message = typeof record.message === 'string' ? record.message : ''
	const details = Array.isArray(record.details)
		? record.details
				.map((item) => {
					if (!item || typeof item !== 'object') return ''
					const detail = item as { parameter?: unknown; message?: unknown }
					const parameter = typeof detail.parameter === 'string' ? detail.parameter : ''
					const detailMessage = typeof detail.message === 'string' ? detail.message : ''
					return [parameter, detailMessage].filter(Boolean).join(': ')
				})
				.filter(Boolean)
				.join('; ')
		: ''
	return [title, message, details].filter(Boolean).join(' — ') || fallback
}

function looksLikeInsufficientScope(status: number, message: string): boolean {
	if (status === 401 || status === 403) return true
	const lower = message.toLowerCase()
	return (
		lower.includes('insufficient') ||
		lower.includes('missing scope') ||
		lower.includes('not authorized') ||
		lower.includes('forbidden') ||
		lower.includes('permission')
	)
}

function pageInfoFromBody(body: unknown): CalendlyPageInfo {
	const pagination =
		body && typeof body === 'object' && (body as { pagination?: unknown }).pagination
			? ((body as { pagination: JsonRecord }).pagination ?? {})
			: {}
	return {
		count: typeof pagination.count === 'number' ? pagination.count : null,
		nextPageToken: typeof pagination.next_page_token === 'string' ? pagination.next_page_token : null,
		previousPageToken:
			typeof pagination.previous_page_token === 'string' ? pagination.previous_page_token : null,
		nextPage: typeof pagination.next_page === 'string' ? pagination.next_page : null,
		previousPage: typeof pagination.previous_page === 'string' ? pagination.previous_page : null,
	}
}

function dataFromBody(body: unknown): unknown {
	if (!body || typeof body !== 'object') return body
	const record = body as JsonRecord
	if (Object.prototype.hasOwnProperty.call(record, 'collection')) return record.collection
	if (Object.prototype.hasOwnProperty.call(record, 'resource')) return record.resource
	return body
}

export async function calendlyRequest<T = unknown>(
	input: CalendlyRequestInput,
): Promise<CalendlyResponse<T>> {
	const method = (input.method ?? 'GET').toUpperCase()
	const path = requireString(input.path, 'path')
	const operation = inferOperationFromPath(method, normalizeCalendlyPath(path), input.operation)
	const auth = await resolveCalendlyAuth(input)
	return calendlyRequestWithAuth<T>(auth, {
		method,
		path,
		query: input.query,
		body: input.body,
		operation,
	})
}

export async function calendlyRequestWithAuth<T = unknown>(
	auth: ResolvedCalendlyAuth,
	input: {
		method: string
		path: string
		query?: JsonRecord
		body?: JsonRecord
		operation: CalendlyOperation
	},
): Promise<CalendlyResponse<T>> {
	const url = calendlyUrl(input.path, input.query)
	const headers = new Headers({
		Accept: 'application/json',
		'User-Agent': 'kody-calendly/1.0',
	})
	const init: RequestInit = { method: input.method, headers }
	if (input.body !== undefined) {
		headers.set('Content-Type', 'application/json')
		init.body = JSON.stringify(input.body)
	}

	const response = await calendlyFetch(auth, url, init)
	const text = await response.text()
	let parsed: unknown = text
	try {
		parsed = text ? JSON.parse(text) : null
	} catch {
		parsed = text
	}
	const message = calendlyMessage(parsed, response.statusText)
	if (!response.ok || looksLikeInsufficientScope(response.status, message)) {
		const missingScope = scopeForOperation(input.operation)
		throw new CalendlyApiError(
			[
				`Calendly ${input.operation} failed (${response.status}): ${message}.`,
				`This call needs the "${missingScope}" permission.`,
				nextStepForScope(missingScope, {
					authMode: auth.mode,
					integrationName: auth.integrationName,
					secretName: auth.secretName,
				}),
			].join(' '),
			{
				status: response.status,
				operation: input.operation,
				details: parsed,
				missingScope,
			},
		)
	}

	return {
		data: dataFromBody(parsed) as T,
		pageInfo: pageInfoFromBody(parsed),
		status: response.status,
	}
}

export { isMutatingMethod, inferOperationFromPath }