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 = { 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( input: AirtableRequestInput, ): Promise> { 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(auth, { method, path, query: input.query, body: input.body, operation, }) } export async function airtableRequestWithAuth( auth: ResolvedAirtableAuth, input: { method: string path: string query?: JsonRecord body?: JsonRecord operation: AirtableOperation }, ): Promise> { 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 }