Skip to content

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

Package listing

@kody/airtable

src/client.ts

200 lines · 5.7 KB · TypeScript
import { airtableFetch, resolveAirtableAuth, type ResolvedAirtableAuth } from './auth.ts'
import type { AirtableAuthInput, AirtablePageInfo, JsonRecord } from './types.ts'
import { requireString } from './types.ts'
import {
	AIRTABLE_API_BASE_URL,
	inferOperationFromPath,
	isMutatingMethod,
	nextStepForScope,
	normalizeAirtablePath,
	scopeForOperation,
	type AirtableOperation,
} from './setup.ts'

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

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

export type AirtableRequestInput = AirtableAuthInput & {
	path: string
	method?: string
	query?: JsonRecord
	body?: JsonRecord
	operation?: AirtableOperation
}

export type AirtableResponse<T> = {
	data: T
	pageInfo: AirtablePageInfo
	status: number
}

function airtableError(body: unknown): { type?: string; message?: string } {
	if (!body || typeof body !== 'object') return {}
	const error = (body as { error?: unknown }).error
	if (typeof error === 'string') return { type: error, message: error }
	if (error && typeof error === 'object') {
		const record = error as { type?: unknown; message?: unknown }
		return {
			type: typeof record.type === 'string' ? record.type : undefined,
			message: typeof record.message === 'string' ? record.message : undefined,
		}
	}
	return {}
}

function looksLikeInsufficientScope(
	status: number,
	error: { type?: string; message?: string },
): boolean {
	if (status === 401 || status === 403) return true
	const type = (error.type ?? '').toUpperCase()
	const message = (error.message ?? '').toLowerCase()
	return (
		type.includes('INVALID_PERMISSIONS') ||
		type.includes('UNAUTHORIZED') ||
		type.includes('FORBIDDEN') ||
		message.includes('insufficient') ||
		message.includes('missing scope') ||
		message.includes('not authorized') ||
		message.includes('forbidden') ||
		message.includes('do not have permission') ||
		message.includes("don't have permission")
	)
}

function appendQueryValue(url: URL, key: string, value: unknown, label: string): void {
	if (value === undefined || value === null) return
	if (typeof value === 'boolean' || typeof value === 'number') {
		url.searchParams.append(key, String(value))
		return
	}
	if (typeof value === 'string') {
		url.searchParams.append(key, value)
		return
	}
	if (Array.isArray(value)) {
		for (const item of value) {
			if (typeof item === 'string' || typeof item === 'number' || typeof item === 'boolean') {
				url.searchParams.append(key, String(item))
				continue
			}
			throw new Error(`${label} items must be a string, number, or boolean.`)
		}
		return
	}
	throw new Error(`${label} must be a string, number, boolean, or array of those.`)
}

export function airtableUrl(path: string, query?: JsonRecord): string {
	const normalized = normalizeAirtablePath(path)
	const url = new URL(normalized, 'https://api.airtable.com')
	if (query) {
		for (const [key, value] of Object.entries(query)) {
			appendQueryValue(url, key, value, `query.${key}`)
		}
	}
	return url.toString()
}

export async function airtableRequest<T = unknown>(
	input: AirtableRequestInput,
): Promise<AirtableResponse<T>> {
	const method = (input.method ?? 'GET').toUpperCase()
	const path = requireString(input.path, 'path')
	const operation = inferOperationFromPath(method, normalizeAirtablePath(path), input.operation)
	const auth = await resolveAirtableAuth(input)
	return airtableRequestWithAuth<T>(auth, {
		method,
		path,
		query: input.query,
		body: input.body,
		operation,
	})
}

export async function airtableRequestWithAuth<T = unknown>(
	auth: ResolvedAirtableAuth,
	input: {
		method: string
		path: string
		query?: JsonRecord
		body?: JsonRecord
		operation: AirtableOperation
	},
): Promise<AirtableResponse<T>> {
	const url = airtableUrl(input.path, input.query)
	const headers = new Headers({
		Accept: 'application/json',
		'User-Agent': 'kody-airtable/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 airtableFetch(auth, url, init)
	const text = await response.text()
	let parsed: unknown = text
	try {
		parsed = text ? JSON.parse(text) : null
	} catch {
		parsed = text
	}
	const error = airtableError(parsed)
	if (!response.ok || looksLikeInsufficientScope(response.status, error)) {
		const missingScope = scopeForOperation(input.operation)
		const firstMessage =
			error.message ||
			(typeof parsed === 'string' ? parsed.slice(0, 400) : response.statusText)
		throw new AirtableApiError(
			[
				`Airtable ${input.operation} failed (${response.status}): ${firstMessage}.`,
				nextStepForScope(missingScope, {
					authMode: auth.mode,
					integrationName: auth.integrationName,
					secretName: auth.secretName,
				}),
			].join(' '),
			{
				status: response.status,
				operation: input.operation,
				details: parsed,
				missingScope,
			},
		)
	}

	const record = parsed && typeof parsed === 'object' ? (parsed as JsonRecord) : {}
	return {
		data: parsed as T,
		pageInfo: {
			hasNextPage: typeof record.offset === 'string' && record.offset.length > 0,
			nextOffset: typeof record.offset === 'string' ? record.offset : null,
		},
		status: response.status,
	}
}

export { AIRTABLE_API_BASE_URL, inferOperationFromPath, isMutatingMethod, normalizeAirtablePath }