Skip to content

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

Package listing

@kody/twilio

src/client.ts

118 lines · 3.0 KB · TypeScript
import { secretHeaders } from 'kody:runtime'
import {
	assertSafeHref,
	nextStepFor,
	resolvePageHref,
	TwilioApiError,
	type JsonRecord,
} from './helpers.ts'
import {
	API_ACCOUNT_PREFIX,
	API_ORIGIN,
	asRecord,
	compactForm,
	resolveAccountSidSecretName,
	resolveAuthTokenSecretName,
	secretPlaceholder,
	type TwilioAuthOptions,
} from './validation.ts'

export {
	API_HOST,
	API_ORIGIN,
	DEFAULT_ACCOUNT_SID_SECRET,
	DEFAULT_AUTH_TOKEN_SECRET,
	accountSidSetupUrl,
	authTokenSetupUrl,
	isDryRun,
	resolveAccountSidSecretName,
	resolveAuthTokenSecretName,
} from './validation.ts'

export {
	TwilioApiError,
	mutationPreview,
	nextStepFor,
	resolvePageHref,
	sanitizeAccount,
} from './helpers.ts'

export function accountResourcePath(accountSidSecret: string, suffix: string): string {
	const path = API_ACCOUNT_PREFIX + secretPlaceholder(accountSidSecret) + suffix
	if (!path.startsWith(API_ACCOUNT_PREFIX)) {
		throw new Error('Twilio account paths must stay under /2010-04-01/Accounts/.')
	}
	return path
}

export type TwilioRequestInput = TwilioAuthOptions & {
	/** Path after /2010-04-01/Accounts/{AccountSid}, e.g. `.json` or `/Messages.json`. */
	resource?: string
	/** Absolute or origin-relative Twilio Accounts API URL (for next_page_uri). */
	href?: string
	method?: 'GET' | 'POST'
	query?: Record<string, unknown>
	body?: Record<string, unknown>
}

export async function twilioRequest(
	input: TwilioRequestInput,
): Promise<JsonRecord> {
	const accountSidSecret = resolveAccountSidSecretName(input)
	const authTokenSecret = resolveAuthTokenSecretName(input)
	const method = input.method ?? 'GET'
	let href: string
	if (input.href) {
		href = resolvePageHref(input.href)
	} else {
		const resource = input.resource ?? '.json'
		if (!resource.startsWith('.') && !resource.startsWith('/')) {
			throw new Error('resource must start with "." or "/".')
		}
		href = API_ORIGIN + accountResourcePath(accountSidSecret, resource)
	}

	const query = compactForm(input.query ?? {})
	if (Object.keys(query).length > 0) {
		const params = new URLSearchParams(query)
		href += (href.includes('?') ? '&' : '?') + params.toString()
	}
	assertSafeHref(href)

	const form = method === 'POST' ? compactForm(input.body ?? {}) : {}
	const response = await fetch(href, {
		method,
		headers: {
			Accept: 'application/json',
			Authorization: secretHeaders.basic({
				usernameSecret: accountSidSecret,
				passwordSecret: authTokenSecret,
				scope: 'user',
			}),
			...(method === 'POST'
				? { 'Content-Type': 'application/x-www-form-urlencoded' }
				: {}),
		},
		body: method === 'POST' ? new URLSearchParams(form) : undefined,
	})

	const text = await response.text()
	let parsed: JsonRecord = {}
	try {
		parsed = text ? (JSON.parse(text) as JsonRecord) : {}
	} catch {
		parsed = { message: text.slice(0, 300) }
	}

	if (!response.ok) {
		const message =
			typeof parsed.message === 'string' ? parsed.message : text.slice(0, 300)
		throw new TwilioApiError(
			response.status,
			parsed,
			nextStepFor(response.status, message, accountSidSecret, authTokenSecret),
		)
	}

	return asRecord(parsed)
}