439 lines
13 KiB
TypeScript

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<ListBasesResponse> {
const params: Record<string, string> = {};
if (offset) params.offset = offset;
const response = await this.metaClient.get<ListBasesResponse>('/bases', { params });
return response.data;
}
async getBase(baseId: BaseId): Promise<Base> {
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<ListTablesResponse> {
const response = await this.metaClient.get<ListTablesResponse>(`/bases/${baseId}/tables`);
return response.data;
}
async getTable(baseId: BaseId, tableId: TableId): Promise<Table> {
const response = await this.metaClient.get<Table>(`/bases/${baseId}/tables/${tableId}`);
return response.data;
}
// ============================================================================
// Fields API (Meta)
// ============================================================================
async listFields(baseId: BaseId, tableId: TableId): Promise<Field[]> {
const table = await this.getTable(baseId, tableId);
return table.fields;
}
async getField(baseId: BaseId, tableId: TableId, fieldId: string): Promise<Field> {
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<View[]> {
const table = await this.getTable(baseId, tableId);
return table.views;
}
async getView(baseId: BaseId, tableId: TableId, viewId: ViewId): Promise<View> {
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<ListRecordsResponse> {
const params: Record<string, unknown> = {};
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<ListRecordsResponse>(`/${baseId}/${encodeURIComponent(tableIdOrName)}`, {
params,
});
return response.data;
}
async getRecord(baseId: BaseId, tableIdOrName: string, recordId: RecordId): Promise<AirtableRecord> {
const response = await this.client.get<AirtableRecord>(
`/${baseId}/${encodeURIComponent(tableIdOrName)}/${recordId}`
);
return response.data;
}
/**
* Create records (max 10 per request)
*/
async createRecords(
baseId: BaseId,
tableIdOrName: string,
records: CreateRecordOptions[],
typecast?: boolean
): Promise<CreateRecordsResponse> {
if (records.length > 10) {
throw new AirtableError('Cannot create more than 10 records per request');
}
const payload: Record<string, unknown> = {
records: records.map((r) => ({ fields: r.fields })),
};
if (typecast !== undefined) {
payload.typecast = typecast;
}
const response = await this.client.post<CreateRecordsResponse>(
`/${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<UpdateRecordsResponse> {
if (records.length > 10) {
throw new AirtableError('Cannot update more than 10 records per request');
}
const payload: Record<string, unknown> = {
records: records.map((r) => ({ id: r.id, fields: r.fields })),
};
if (typecast !== undefined) {
payload.typecast = typecast;
}
const response = await this.client.patch<UpdateRecordsResponse>(
`/${baseId}/${encodeURIComponent(tableIdOrName)}`,
payload
);
return response.data;
}
/**
* Delete records (max 10 per request)
*/
async deleteRecords(
baseId: BaseId,
tableIdOrName: string,
recordIds: RecordId[]
): Promise<DeleteRecordsResult> {
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<DeleteRecordsResult>(
`/${baseId}/${encodeURIComponent(tableIdOrName)}?${params}`
);
return response.data;
}
// ============================================================================
// Webhooks API
// ============================================================================
async listWebhooks(baseId: BaseId): Promise<Webhook[]> {
const response = await this.client.get<{ webhooks: Webhook[] }>(`/${baseId}/webhooks`);
return response.data.webhooks;
}
async createWebhook(baseId: BaseId, notificationUrl: string, specification: unknown): Promise<Webhook> {
const response = await this.client.post<Webhook>(`/${baseId}/webhooks`, {
notificationUrl,
specification,
});
return response.data;
}
async deleteWebhook(baseId: BaseId, webhookId: WebhookId): Promise<void> {
await this.client.delete(`/${baseId}/webhooks/${webhookId}`);
}
async refreshWebhook(baseId: BaseId, webhookId: WebhookId): Promise<Webhook> {
const response = await this.client.post<Webhook>(`/${baseId}/webhooks/${webhookId}/refresh`);
return response.data;
}
// ============================================================================
// Comments API
// ============================================================================
async listComments(baseId: BaseId, tableIdOrName: string, recordId: RecordId): Promise<Comment[]> {
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<Comment> {
const response = await this.client.post<Comment>(
`/${baseId}/${encodeURIComponent(tableIdOrName)}/${recordId}/comments`,
{ text }
);
return response.data;
}
async updateComment(
baseId: BaseId,
tableIdOrName: string,
recordId: RecordId,
commentId: string,
text: string
): Promise<Comment> {
const response = await this.client.patch<Comment>(
`/${baseId}/${encodeURIComponent(tableIdOrName)}/${recordId}/comments/${commentId}`,
{ text }
);
return response.data;
}
async deleteComment(
baseId: BaseId,
tableIdOrName: string,
recordId: RecordId,
commentId: string
): Promise<void> {
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<Automation[]> {
throw new AirtableError('Automations API not yet supported by Airtable');
}
}