import axios, { AxiosInstance, AxiosError } from 'axios'; import type { Base, BaseId, Table, TableId, AirtableRecord, RecordId, RecordFields, Field, View, ViewId, Webhook, WebhookId, Automation, Comment, FilterFormula, SortConfig, ListBasesResponse, ListTablesResponse, ListRecordsResponse, CreateRecordsResponse, UpdateRecordsResponse, DeleteRecordsResponse, } from '../types/index.js'; export interface AirtableClientConfig { apiKey: string; baseUrl?: string; metaBaseUrl?: string; maxRetries?: number; retryDelayMs?: number; } export interface ListRecordsOptions { fields?: string[]; filterByFormula?: FilterFormula; maxRecords?: number; pageSize?: number; sort?: SortConfig[]; view?: string; cellFormat?: 'json' | 'string'; timeZone?: string; userLocale?: string; offset?: string; returnFieldsByFieldId?: boolean; } export interface CreateRecordOptions { fields: RecordFields; typecast?: boolean; } export interface UpdateRecordOptions { fields: RecordFields; typecast?: boolean; } export interface DeleteRecordsResult { records: Array<{ id: RecordId; deleted: boolean }>; } export class AirtableError extends Error { constructor( message: string, public statusCode?: number, public response?: unknown ) { super(message); this.name = 'AirtableError'; } } /** * Airtable API Client * * Official API Documentation: https://airtable.com/developers/web/api/introduction * * Rate Limits: * - 5 requests per second per base * - Implement exponential backoff for 429 responses * * Base URLs: * - Records API: https://api.airtable.com/v0 * - Meta API: https://api.airtable.com/v0/meta * * Pagination: * - Records: offset-based (include `offset` from response in next request) * - Meta endpoints: cursor-based (vary by endpoint) * * Batch Limits: * - Create/Update: max 10 records per request * - Delete: max 10 record IDs per request */ export class AirtableClient { private client: AxiosInstance; private metaClient: AxiosInstance; private maxRetries: number; private retryDelayMs: number; constructor(config: AirtableClientConfig) { const baseUrl = config.baseUrl || 'https://api.airtable.com/v0'; const metaBaseUrl = config.metaBaseUrl || 'https://api.airtable.com/v0/meta'; this.maxRetries = config.maxRetries || 3; this.retryDelayMs = config.retryDelayMs || 1000; this.client = axios.create({ baseURL: baseUrl, headers: { Authorization: `Bearer ${config.apiKey}`, 'Content-Type': 'application/json', }, timeout: 30000, }); this.metaClient = axios.create({ baseURL: metaBaseUrl, headers: { Authorization: `Bearer ${config.apiKey}`, 'Content-Type': 'application/json', }, timeout: 30000, }); this.setupInterceptors(); } private setupInterceptors(): void { const retryInterceptor = async (error: AxiosError) => { const config = error.config as any; if (!config || !error.response) { throw error; } // Handle rate limiting (429) if (error.response.status === 429) { const retryCount = config.__retryCount || 0; if (retryCount < this.maxRetries) { config.__retryCount = retryCount + 1; const delay = this.retryDelayMs * Math.pow(2, retryCount); await new Promise((resolve) => setTimeout(resolve, delay)); return this.client.request(config); } } throw this.handleError(error); }; this.client.interceptors.response.use( (response) => response, retryInterceptor ); this.metaClient.interceptors.response.use( (response) => response, retryInterceptor ); } private handleError(error: AxiosError): AirtableError { if (error.response) { const data = error.response.data as any; const message = data?.error?.message || data?.message || 'Airtable API error'; return new AirtableError(message, error.response.status, data); } return new AirtableError(error.message); } // ============================================================================ // Bases API // ============================================================================ async listBases(offset?: string): Promise { const params: Record = {}; if (offset) params.offset = offset; const response = await this.metaClient.get('/bases', { params }); return response.data; } async getBase(baseId: BaseId): Promise { const response = await this.metaClient.get<{ id: string; name: string; permissionLevel: string }>( `/bases/${baseId}` ); return response.data as Base; } // ============================================================================ // Tables API (Meta) // ============================================================================ async listTables(baseId: BaseId): Promise { const response = await this.metaClient.get(`/bases/${baseId}/tables`); return response.data; } async getTable(baseId: BaseId, tableId: TableId): Promise { const response = await this.metaClient.get
(`/bases/${baseId}/tables/${tableId}`); return response.data; } // ============================================================================ // Fields API (Meta) // ============================================================================ async listFields(baseId: BaseId, tableId: TableId): Promise { const table = await this.getTable(baseId, tableId); return table.fields; } async getField(baseId: BaseId, tableId: TableId, fieldId: string): Promise { const fields = await this.listFields(baseId, tableId); const field = fields.find((f) => f.id === fieldId); if (!field) { throw new AirtableError(`Field ${fieldId} not found in table ${tableId}`); } return field; } // ============================================================================ // Views API (Meta) // ============================================================================ async listViews(baseId: BaseId, tableId: TableId): Promise { const table = await this.getTable(baseId, tableId); return table.views; } async getView(baseId: BaseId, tableId: TableId, viewId: ViewId): Promise { const views = await this.listViews(baseId, tableId); const view = views.find((v) => v.id === viewId); if (!view) { throw new AirtableError(`View ${viewId} not found in table ${tableId}`); } return view; } // ============================================================================ // Records API // ============================================================================ async listRecords( baseId: BaseId, tableIdOrName: string, options?: ListRecordsOptions ): Promise { const params: Record = {}; if (options?.fields) params.fields = options.fields; if (options?.filterByFormula) params.filterByFormula = options.filterByFormula; if (options?.maxRecords) params.maxRecords = options.maxRecords; if (options?.pageSize) params.pageSize = options.pageSize; if (options?.view) params.view = options.view; if (options?.cellFormat) params.cellFormat = options.cellFormat; if (options?.timeZone) params.timeZone = options.timeZone; if (options?.userLocale) params.userLocale = options.userLocale; if (options?.offset) params.offset = options.offset; if (options?.returnFieldsByFieldId) params.returnFieldsByFieldId = options.returnFieldsByFieldId; if (options?.sort) { options.sort.forEach((sortItem, index) => { params[`sort[${index}][field]`] = sortItem.field; params[`sort[${index}][direction]`] = sortItem.direction; }); } const response = await this.client.get(`/${baseId}/${encodeURIComponent(tableIdOrName)}`, { params, }); return response.data; } async getRecord(baseId: BaseId, tableIdOrName: string, recordId: RecordId): Promise { const response = await this.client.get( `/${baseId}/${encodeURIComponent(tableIdOrName)}/${recordId}` ); return response.data; } /** * Create records (max 10 per request) */ async createRecords( baseId: BaseId, tableIdOrName: string, records: CreateRecordOptions[], typecast?: boolean ): Promise { if (records.length > 10) { throw new AirtableError('Cannot create more than 10 records per request'); } const payload: Record = { records: records.map((r) => ({ fields: r.fields })), }; if (typecast !== undefined) { payload.typecast = typecast; } const response = await this.client.post( `/${baseId}/${encodeURIComponent(tableIdOrName)}`, payload ); return response.data; } /** * Update records (max 10 per request) */ async updateRecords( baseId: BaseId, tableIdOrName: string, records: Array<{ id: RecordId } & UpdateRecordOptions>, typecast?: boolean ): Promise { if (records.length > 10) { throw new AirtableError('Cannot update more than 10 records per request'); } const payload: Record = { records: records.map((r) => ({ id: r.id, fields: r.fields })), }; if (typecast !== undefined) { payload.typecast = typecast; } const response = await this.client.patch( `/${baseId}/${encodeURIComponent(tableIdOrName)}`, payload ); return response.data; } /** * Delete records (max 10 per request) */ async deleteRecords( baseId: BaseId, tableIdOrName: string, recordIds: RecordId[] ): Promise { if (recordIds.length > 10) { throw new AirtableError('Cannot delete more than 10 records per request'); } const params = recordIds.map((id) => `records[]=${id}`).join('&'); const response = await this.client.delete( `/${baseId}/${encodeURIComponent(tableIdOrName)}?${params}` ); return response.data; } // ============================================================================ // Webhooks API // ============================================================================ async listWebhooks(baseId: BaseId): Promise { const response = await this.client.get<{ webhooks: Webhook[] }>(`/${baseId}/webhooks`); return response.data.webhooks; } async createWebhook(baseId: BaseId, notificationUrl: string, specification: unknown): Promise { const response = await this.client.post(`/${baseId}/webhooks`, { notificationUrl, specification, }); return response.data; } async deleteWebhook(baseId: BaseId, webhookId: WebhookId): Promise { await this.client.delete(`/${baseId}/webhooks/${webhookId}`); } async refreshWebhook(baseId: BaseId, webhookId: WebhookId): Promise { const response = await this.client.post(`/${baseId}/webhooks/${webhookId}/refresh`); return response.data; } // ============================================================================ // Comments API // ============================================================================ async listComments(baseId: BaseId, tableIdOrName: string, recordId: RecordId): Promise { const response = await this.client.get<{ comments: Comment[] }>( `/${baseId}/${encodeURIComponent(tableIdOrName)}/${recordId}/comments` ); return response.data.comments; } async createComment(baseId: BaseId, tableIdOrName: string, recordId: RecordId, text: string): Promise { const response = await this.client.post( `/${baseId}/${encodeURIComponent(tableIdOrName)}/${recordId}/comments`, { text } ); return response.data; } async updateComment( baseId: BaseId, tableIdOrName: string, recordId: RecordId, commentId: string, text: string ): Promise { const response = await this.client.patch( `/${baseId}/${encodeURIComponent(tableIdOrName)}/${recordId}/comments/${commentId}`, { text } ); return response.data; } async deleteComment( baseId: BaseId, tableIdOrName: string, recordId: RecordId, commentId: string ): Promise { await this.client.delete(`/${baseId}/${encodeURIComponent(tableIdOrName)}/${recordId}/comments/${commentId}`); } // ============================================================================ // Automations API (Read-only for now) // ============================================================================ /** * Note: Airtable's API does not currently provide direct automation management. * This is a placeholder for future functionality or webhook-based automation triggers. */ async listAutomations(baseId: BaseId): Promise { throw new AirtableError('Automations API not yet supported by Airtable'); } }