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 }