439 lines
13 KiB
TypeScript
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');
|
|
}
|
|
}
|