Skip to main content
Glama
aitableService.ts11.3 kB
import nodeFetch, { RequestInit } from "node-fetch"; import type { attachmentVO, FieldFormatJSONSchema, FieldSchemaVO, RecordVO, ResponseVO, SelectFieldOptionVO, } from "./types.js"; export class AitableService { private readonly apiKey: string; private readonly baseUrl: string; private readonly fetch: typeof nodeFetch; constructor( apiKey: string, baseUrl: string, fetch: typeof nodeFetch = nodeFetch ) { if (!apiKey) { console.log("Please set the AITABLE_API_KEY environment variable."); } this.apiKey = apiKey; this.baseUrl = baseUrl; this.fetch = fetch; } public async fetchFromAPI<T>( endpoint: string, options: RequestInit = {} ): Promise<ResponseVO<T>> { const response = await this.fetch(`${this.baseUrl}${endpoint}`, { ...options, headers: { Authorization: `Bearer ${this.apiKey}`, Accept: "application/json", "Content-Type": "application/json", ...options.headers, }, }); const responseJson = (await response.json()) as ResponseVO<T>; if (!response.ok) { throw new Error( `AITable API Error: ${response.statusText}. Response: ${responseJson.message}` ); } try { return responseJson; } catch (error) { throw new Error( `Failed to parse API response: ${ error instanceof Error ? error.message : String(error) }` ); } } /** * Validates required parameters and builds a query string from provided parameters * @param params Object containing parameters to validate and convert to query string * @param requiredParams Array of parameter names that are required * @returns Formatted query string starting with '?' if parameters exist */ public buildQueryString( params: Record<string, any>, requiredParams: string[] = [] ): string { // Validate required parameters for (const param of requiredParams) { if ( params[param] === undefined || params[param] === null || params[param] === "" ) { throw new Error(`Query parameter '${param}' is required.`); } } // Build query parameters const queryParams: string[] = []; for (const [key, value] of Object.entries(params)) { if (value === undefined || value === null) continue; if (Array.isArray(value)) { // Handle array parameters (like sort) if (value.length > 0) { const arrayStr = JSON.stringify(value); queryParams.push( `${encodeURIComponent(key)}=${encodeURIComponent(arrayStr)}` ); } } else if (typeof value === "object") { // Handle object parameters const objStr = JSON.stringify(value); queryParams.push( `${encodeURIComponent(key)}=${encodeURIComponent(objStr)}` ); } else if (value !== "") { // Handle primitive parameters queryParams.push( `${encodeURIComponent(key)}=${encodeURIComponent(value.toString())}` ); } } return queryParams.length > 0 ? `?${queryParams.join("&")}` : ""; } /** * get the type-specific keywords for a field based on its type. * * warning: To use Structured Outputs, all fields or function parameters must be specified as `required`. * Although all fields must be required (and the model will return a value for each parameter), it is possible to emulate an optional parameter by using a union type with `null`. */ private _getKeywordByFieldType(field: FieldSchemaVO): object | null { if ( ["Text", "SingleText", "Email ", "URL", "Phone"].includes(field.type) ) { return { type: "string" }; } if (field.type === "Checkbox") { return { type: "boolean" }; } if (["Number", "Currency", "Percent"].includes(field.type)) { return { type: "number" }; } if (field.type === "Rating") { // openAI does not support `max` property in JSON schema, so we will just use description for providing the range const description = field.property && "max" in field.property ? `Rating from 0 to ${field.property.max}. if over ${field.property.max}, reduce to ${field.property.max}` : ""; return { type: "integer", description }; } if (field.type === "DateTime") { return { type: "string", description: "ISO 8601 date-time format, with UTC timezone. Example: 2022-01-01T12:00:00Z", }; } if (field.type === "SingleSelect") { return { type: "string", enum: field.property?.options?.map( (option: SelectFieldOptionVO) => option.name ), description: "Single selection from the provided options. If no options are available, return a null value.", } } if (field.type === "MultiSelect") { if (field.property && "options" in field.property) { const enumValues = field.property.options.map( (option: SelectFieldOptionVO) => option.name ); return { type: "array", items: { type: "string", enum: enumValues, description: "one or more selections", }, }; } } if (field.type === "Attachment") { return { type: "array", items: { properties: { token: { type: "string" }, name: { type: "string" }, mimeType: { type: "string" }, url: { type: "string" }, size: { type: "number" }, height: { type: "number", nullable: true }, width: { type: "number", nullable: true }, }, } }; } // TODO: Add support for MEMBER field return null; // Return null if the field type is not supported } private _getCellValueByFieldType( field: FieldSchemaVO, fieldValue: unknown ): unknown { if (field.type === "Text" || field.type === "SingleText" || field.type === "Email" || field.type === "URL" || field.type === "Phone" || field.type === "SingleSelect" ) { return String(fieldValue); } if (field.type === "Checkbox") { return Boolean(fieldValue); } if (["Number", "Currency", "Percent", "Rating"].includes(field.type)) { return Number(fieldValue); } if (field.type === "DateTime" && typeof fieldValue === "string") { return fieldValue !== "" ? new Date(String(fieldValue)).toISOString() : null; } if (field.type === "MultiSelect") { if (Array.isArray(fieldValue)) { const validOptionIds = fieldValue .map((optionName: string) => { const option = (field.property?.options as SelectFieldOptionVO[])?.find( (option: SelectFieldOptionVO) => option.name === optionName ); return option ? option.id : null; }) .filter((id): id is string => id !== null); return validOptionIds; } else if (typeof fieldValue === "string") { const option = (field.property?.options as SelectFieldOptionVO[])?.find( (option: SelectFieldOptionVO) => option.name === fieldValue ); return option ? [option.id] : []; } } return null; // Return null for unsupported field types } public convertFieldValuesToCellFormat( fieldsSchema: FieldSchemaVO[], fieldValues: Record<string, unknown> ): Record<string, unknown> { const cells: Record<string, unknown> = {}; fieldsSchema.forEach((fieldschema) => { const fieldValue = fieldValues[fieldschema.name]; if (fieldValue !== undefined) { const cellValue = this._getCellValueByFieldType( fieldschema, fieldValue ); // Only add the cell if the value is not null if (cellValue !== null) { cells[fieldschema.name] = cellValue; } } }); return cells; } /** * Generate a JSON schema based on the provided fields. * This schema will be sent to OpenAI to help the AI understand the expected structure of the data. */ public getFieldsJSONSchema(fields: FieldSchemaVO[]): FieldFormatJSONSchema { const schema: { type: string; properties: Record<string, unknown>; additionalProperties: boolean; required: string[]; } = { type: "object", properties: {}, additionalProperties: false, required: [], }; fields.forEach((field) => { const keywordsForField = this._getKeywordByFieldType(field); // If the field type is not supported, we skip it if (keywordsForField) { schema.properties[field.name] = keywordsForField; schema.required.push(field.name); } }); return { type: "json_schema", json_schema: { name: "fields_in_datasheet", schema, strict: true, }, }; } private async _fetchFileViaURL(file_url: string): Promise<Blob> { if (!file_url) { throw new Error("file_url is required."); } const response = await this.fetch(file_url) if (!response.ok) { throw new Error( `Fetch file error: ${response.statusText}. Response: ${await response.text()}` ); } return response.blob(); } public async uploadFileToSpace( node_id: string, file_url: string, file_name?: string, ): Promise<ResponseVO<attachmentVO[]>> { // get the file name from file_url if not provided if(!file_name) { const url = new URL(file_url); file_name = url.pathname.split("/").pop() ?? "file_"+new Date().getTime(); } // Fetch the file from the provided URL const fileBlob = await this._fetchFileViaURL(file_url); const formData = new FormData(); formData.append("file", fileBlob, file_name); const endpoint = `/v1/datasheets/${node_id}/attachments`; const response = await this.fetch(`${this.baseUrl}${endpoint}`, { headers: { Authorization: `Bearer ${this.apiKey}`, }, body: formData, method: "POST", }); const responseJson = (await response.json()) as ResponseVO<attachmentVO[]>; if (!response.ok) { throw new Error( `AITable API Error: ${response.statusText}. Response: ${responseJson.message}` ); } return responseJson; } public async getDatasheetFieldsSchema( node_id: string ): Promise<ResponseVO<{fields: FieldSchemaVO[]}>> { if (!node_id) { throw new Error("The datasheet ID (node_id) is required."); } const endpoint = `/v1/datasheets/${node_id}/fields`; return this.fetchFromAPI(endpoint, { method: "GET", }); } public async createDatasheetRecord( node_id: string, cells: Record<string, unknown> ): Promise<ResponseVO<{records: RecordVO[]}>> { const endpoint = `/v1/datasheets/${node_id}/records`; console.error('Creating record with cells:', cells); return this.fetchFromAPI(endpoint, { method: "POST", body: JSON.stringify({ records:[ { fields: cells, } ] }), }); } }

Implementation Reference

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/apitable/aitable-mcp-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server