mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
feat(tools): added airtable integration for oauth2 and tools (#214)
* feat(airtable): add Airtable integration with OAuth support ref #102 * added scope descriptions for airtable oauth --------- Co-authored-by: rodrick-mpofu <rodrickmpofu@gmail.com>
This commit is contained in:
co-authored by
rodrick-mpofu
parent
f835441c50
commit
7dde4191c7
+2
@@ -59,6 +59,8 @@ const SCOPE_DESCRIPTIONS: Record<string, string> = {
|
||||
'tweet.write': 'Post tweets on your behalf',
|
||||
'users.read': 'Read your profile information',
|
||||
'offline.access': 'Access your account when you are not using the application',
|
||||
'data.records:read': 'Read your records',
|
||||
'data.records:write': 'Write to your records',
|
||||
}
|
||||
|
||||
// Convert OAuth scope to user-friendly description
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*
|
||||
* Airtable Block Integration Tests
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import { AirtableBlock } from './airtable'
|
||||
|
||||
describe('Airtable Block', () => {
|
||||
test('should have correct configuration', () => {
|
||||
expect(AirtableBlock.type).toBe('airtable')
|
||||
expect(AirtableBlock.category).toBe('tools')
|
||||
expect(AirtableBlock.tools.access).toContain('airtable_read')
|
||||
expect(AirtableBlock.tools.access).toContain('airtable_write')
|
||||
expect(AirtableBlock.tools.access).toContain('airtable_update')
|
||||
})
|
||||
|
||||
test('should select correct tool based on operation', () => {
|
||||
const config = AirtableBlock.tools.config
|
||||
|
||||
if (!config) {
|
||||
throw new Error('Tool config is missing')
|
||||
}
|
||||
|
||||
expect(config.tool({ operation: 'read' })).toBe('airtable_read')
|
||||
expect(config.tool({ operation: 'write' })).toBe('airtable_write')
|
||||
expect(config.tool({ operation: 'update' })).toBe('airtable_update')
|
||||
expect(() => config.tool({ operation: 'invalid' })).toThrow('Invalid Airtable operation')
|
||||
})
|
||||
|
||||
test('should transform parameters correctly', () => {
|
||||
const config = AirtableBlock.tools.config
|
||||
|
||||
if (!config || !config.params) {
|
||||
throw new Error('Tool config or params is missing')
|
||||
}
|
||||
|
||||
// Test read operation
|
||||
const readParams = config.params({
|
||||
operation: 'read',
|
||||
credential: 'oauth_token',
|
||||
baseId: 'base123',
|
||||
tableId: 'table456',
|
||||
maxRecords: '100',
|
||||
filterFormula: "Status='Active'"
|
||||
})
|
||||
|
||||
expect(readParams.accessToken).toBe('oauth_token')
|
||||
expect(readParams.baseId).toBe('base123')
|
||||
expect(readParams.tableId).toBe('table456')
|
||||
|
||||
// Test write operation
|
||||
const writeParams = config.params({
|
||||
operation: 'write',
|
||||
credential: 'oauth_token',
|
||||
baseId: 'base123',
|
||||
tableId: 'table456',
|
||||
records: JSON.stringify([{ fields: { Name: 'Test' } }])
|
||||
})
|
||||
|
||||
expect(writeParams.accessToken).toBe('oauth_token')
|
||||
expect(writeParams.records).toEqual([{ fields: { Name: 'Test' } }])
|
||||
|
||||
// Test update operation
|
||||
const updateParams = config.params({
|
||||
operation: 'update',
|
||||
credential: 'oauth_token',
|
||||
baseId: 'base123',
|
||||
tableId: 'table456',
|
||||
recordId: 'rec789',
|
||||
records: JSON.stringify({ fields: { Name: 'Updated' } })
|
||||
})
|
||||
|
||||
expect(updateParams.accessToken).toBe('oauth_token')
|
||||
expect(updateParams.recordId).toBe('rec789')
|
||||
expect(updateParams.fields).toEqual({ fields: { Name: 'Updated' } })
|
||||
})
|
||||
|
||||
test('should have required OAuth configuration', () => {
|
||||
const subBlocks = AirtableBlock.subBlocks
|
||||
const credentialBlock = subBlocks.find(block => block.id === 'credential')
|
||||
|
||||
expect(credentialBlock).toBeDefined()
|
||||
expect(credentialBlock?.type).toBe('oauth-input')
|
||||
expect(credentialBlock?.provider).toBe('airtable')
|
||||
expect(credentialBlock?.requiredScopes).toContain('data.records:read')
|
||||
expect(credentialBlock?.requiredScopes).toContain('data.records:write')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,174 @@
|
||||
import { AirtableIcon } from '@/components/icons'
|
||||
import {
|
||||
AirtableReadResponse,
|
||||
AirtableWriteResponse,
|
||||
AirtableUpdateResponse,
|
||||
} from '@/tools/airtable/types'
|
||||
import { BlockConfig } from '../types'
|
||||
|
||||
type AirtableResponse = AirtableReadResponse | AirtableWriteResponse | AirtableUpdateResponse
|
||||
|
||||
export const AirtableBlock: BlockConfig<AirtableResponse> = {
|
||||
type: 'airtable',
|
||||
name: 'Airtable',
|
||||
description: 'Read, write, and update Airtable records',
|
||||
longDescription:
|
||||
'Integrate Airtable functionality to manage table records. Read data from existing tables, ' +
|
||||
'write new records, and update existing ones using OAuth authentication. Supports table ' +
|
||||
'selection and record operations with custom field mapping.',
|
||||
category: 'tools',
|
||||
bgColor: '#FCB400',
|
||||
icon: AirtableIcon,
|
||||
subBlocks: [
|
||||
// Operation selector
|
||||
{
|
||||
id: 'operation',
|
||||
title: 'Operation',
|
||||
type: 'dropdown',
|
||||
layout: 'full',
|
||||
options: [
|
||||
{ label: 'Read Records', id: 'read' },
|
||||
{ label: 'Write Records', id: 'write' },
|
||||
{ label: 'Update Records', id: 'update' },
|
||||
],
|
||||
},
|
||||
// Airtable Credentials
|
||||
{
|
||||
id: 'credential',
|
||||
title: 'Airtable Account',
|
||||
type: 'oauth-input',
|
||||
layout: 'full',
|
||||
provider: 'airtable',
|
||||
serviceId: 'airtable',
|
||||
requiredScopes: ['data.records:read', 'data.records:write'],
|
||||
placeholder: 'Select Airtable account',
|
||||
},
|
||||
// Base ID
|
||||
{
|
||||
id: 'baseId',
|
||||
title: 'Base ID',
|
||||
type: 'short-input',
|
||||
layout: 'full',
|
||||
placeholder: 'Enter your base ID (found in the API documentation)',
|
||||
},
|
||||
// Table Name/ID
|
||||
{
|
||||
id: 'tableId',
|
||||
title: 'Table Name/ID',
|
||||
type: 'short-input',
|
||||
layout: 'full',
|
||||
placeholder: 'Enter table name or ID',
|
||||
},
|
||||
// Read Operation Fields
|
||||
{
|
||||
id: 'maxRecords',
|
||||
title: 'Max Records',
|
||||
type: 'short-input',
|
||||
layout: 'half',
|
||||
placeholder: 'Maximum number of records to return',
|
||||
condition: { field: 'operation', value: 'read' },
|
||||
},
|
||||
{
|
||||
id: 'filterFormula',
|
||||
title: 'Filter Formula',
|
||||
type: 'long-input',
|
||||
layout: 'full',
|
||||
placeholder: 'Enter Airtable formula to filter records',
|
||||
condition: { field: 'operation', value: 'read' },
|
||||
},
|
||||
// Write Operation Fields
|
||||
{
|
||||
id: 'records',
|
||||
title: 'Records',
|
||||
type: 'code',
|
||||
layout: 'full',
|
||||
placeholder: 'Enter records in JSON format',
|
||||
condition: { field: 'operation', value: 'write' },
|
||||
},
|
||||
// Update Operation Fields
|
||||
{
|
||||
id: 'records',
|
||||
title: 'Record Fields',
|
||||
type: 'code',
|
||||
layout: 'full',
|
||||
placeholder: 'Enter record fields in JSON format',
|
||||
condition: { field: 'operation', value: 'update' },
|
||||
},
|
||||
{
|
||||
id: 'recordId',
|
||||
title: 'Record ID',
|
||||
type: 'short-input',
|
||||
layout: 'full',
|
||||
placeholder: 'ID of the record to update',
|
||||
condition: { field: 'operation', value: 'update' },
|
||||
},
|
||||
],
|
||||
tools: {
|
||||
access: ['airtable_read', 'airtable_write', 'airtable_update'],
|
||||
config: {
|
||||
tool: (params) => {
|
||||
switch (params.operation) {
|
||||
case 'read':
|
||||
return 'airtable_read'
|
||||
case 'write':
|
||||
return 'airtable_write'
|
||||
case 'update':
|
||||
return 'airtable_update'
|
||||
default:
|
||||
throw new Error(`Invalid Airtable operation: ${params.operation}`)
|
||||
}
|
||||
},
|
||||
params: (params) => {
|
||||
const { credential, records, ...rest } = params
|
||||
|
||||
if (params.operation === 'update' && records) {
|
||||
const parsedRecords = JSON.parse(records)
|
||||
return {
|
||||
accessToken: credential,
|
||||
fields: parsedRecords,
|
||||
...rest,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken: credential,
|
||||
records: records ? JSON.parse(records) : undefined,
|
||||
...rest,
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
inputs: {
|
||||
operation: { type: 'string', required: true },
|
||||
credential: { type: 'string', required: true },
|
||||
baseId: { type: 'string', required: true },
|
||||
tableId: { type: 'string', required: true },
|
||||
// Read operation inputs
|
||||
maxRecords: { type: 'number', required: false },
|
||||
filterFormula: { type: 'string', required: false },
|
||||
// Write/Update operation inputs
|
||||
records: {
|
||||
type: 'json',
|
||||
required: false,
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
fields: {
|
||||
type: 'object',
|
||||
additionalProperties: true,
|
||||
},
|
||||
},
|
||||
required: ['fields'],
|
||||
},
|
||||
},
|
||||
recordId: { type: 'string', required: false },
|
||||
},
|
||||
outputs: {
|
||||
response: {
|
||||
type: {
|
||||
records: 'json',
|
||||
metadata: 'json',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -33,11 +33,13 @@ import { VisionBlock } from './blocks/vision'
|
||||
import { WhatsAppBlock } from './blocks/whatsapp'
|
||||
import { XBlock } from './blocks/x'
|
||||
import { YouTubeBlock } from './blocks/youtube'
|
||||
import { AirtableBlock } from './blocks/airtable'
|
||||
import { BlockConfig } from './types'
|
||||
|
||||
// Export blocks for ease of use
|
||||
export {
|
||||
AgentBlock,
|
||||
AirtableBlock,
|
||||
ApiBlock,
|
||||
FileBlock,
|
||||
FunctionBlock,
|
||||
@@ -76,6 +78,7 @@ export {
|
||||
// Registry of all block configurations, alphabetically sorted
|
||||
const blocks: Record<string, BlockConfig> = {
|
||||
agent: AgentBlock,
|
||||
airtable: AirtableBlock,
|
||||
api: ApiBlock,
|
||||
condition: ConditionBlock,
|
||||
confluence: ConfluenceBlock,
|
||||
|
||||
+11
-25
@@ -1516,33 +1516,19 @@ export function RedditIcon(props: SVGProps<SVGSVGElement>) {
|
||||
export function AirtableIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
{...props}
|
||||
width="800px"
|
||||
height="800px"
|
||||
viewBox="0 -20.5 256 256"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
preserveAspectRatio="xMidYMid"
|
||||
>
|
||||
<g>
|
||||
<path
|
||||
d="M114.25873,2.70101695 L18.8604023,42.1756384 C13.5552723,44.3711638 13.6102328,51.9065311 18.9486282,54.0225085 L114.746142,92.0117514 C123.163769,95.3498757 132.537419,95.3498757 140.9536,92.0117514 L236.75256,54.0225085 C242.08951,51.9065311 242.145916,44.3711638 236.83934,42.1756384 L141.442459,2.70101695 C132.738459,-0.900338983 122.961284,-0.900338983 114.25873,2.70101695"
|
||||
fill="#FFBF00"
|
||||
></path>
|
||||
<path
|
||||
d="M136.349071,112.756863 L136.349071,207.659101 C136.349071,212.173089 140.900664,215.263892 145.096461,213.600615 L251.844122,172.166219 C254.281184,171.200072 255.879376,168.845451 255.879376,166.224705 L255.879376,71.3224678 C255.879376,66.8084791 251.327783,63.7176768 247.131986,65.3809537 L140.384325,106.815349 C137.94871,107.781496 136.349071,110.136118 136.349071,112.756863"
|
||||
fill="#26B5F8"
|
||||
></path>
|
||||
<path
|
||||
d="M111.422771,117.65355 L79.742409,132.949912 L76.5257763,134.504714 L9.65047684,166.548104 C5.4112904,168.593211 0.000578531073,165.503855 0.000578531073,160.794612 L0.000578531073,71.7210757 C0.000578531073,70.0173017 0.874160452,68.5463864 2.04568588,67.4384994 C2.53454463,66.9481944 3.08848814,66.5446689 3.66412655,66.2250305 C5.26231864,65.2661153 7.54173107,65.0101153 9.47981017,65.7766689 L110.890522,105.957098 C116.045234,108.002206 116.450206,115.225166 111.422771,117.65355"
|
||||
fill="#ED3049"
|
||||
></path>
|
||||
<path
|
||||
d="M111.422771,117.65355 L79.742409,132.949912 L2.04568588,67.4384994 C2.53454463,66.9481944 3.08848814,66.5446689 3.66412655,66.2250305 C5.26231864,65.2661153 7.54173107,65.0101153 9.47981017,65.7766689 L110.890522,105.957098 C116.045234,108.002206 116.450206,115.225166 111.422771,117.65355"
|
||||
fillOpacity="0.25"
|
||||
fill="#000000"
|
||||
></path>
|
||||
</g>
|
||||
<path d="M4 9.5V4a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v5.5" />
|
||||
<path d="M6.5 16.5v-4.25c0-.41.34-.75.75-.75h9.5c.41 0 .75.34.75.75v4.25" />
|
||||
<path d="M3 16.5h18" />
|
||||
<path d="M4 21h16a1 1 0 0 0 1-1v-3.5H3V20a1 1 0 0 0 1 1z" />
|
||||
<rect x="7" y="8" width="10" height="3" rx="0.5" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -412,6 +412,55 @@ export const auth = betterAuth({
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// Airtable provider
|
||||
{
|
||||
providerId: 'airtable',
|
||||
clientId: process.env.AIRTABLE_CLIENT_ID as string,
|
||||
clientSecret: process.env.AIRTABLE_CLIENT_SECRET as string,
|
||||
authorizationUrl: 'https://airtable.com/oauth/authorize',
|
||||
tokenUrl: 'https://airtable.com/oauth/token',
|
||||
userInfoUrl: 'https://api.airtable.com/v0/meta/whoami',
|
||||
scopes: ['data.records:read', 'data.records:write'],
|
||||
responseType: 'code',
|
||||
pkce: true,
|
||||
accessType: 'offline',
|
||||
prompt: 'consent',
|
||||
redirectURI: `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/oauth2/callback/airtable`,
|
||||
getUserInfo: async (tokens) => {
|
||||
try {
|
||||
const response = await fetch('https://api.airtable.com/v0/meta/whoami', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens.accessToken}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
logger.error('Error fetching Airtable user info:', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
const profile = await response.json()
|
||||
const now = new Date()
|
||||
|
||||
return {
|
||||
id: profile.id,
|
||||
name: profile.name || 'Airtable User',
|
||||
email: profile.email,
|
||||
image: null,
|
||||
emailVerified: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error in Airtable getUserInfo:', { error })
|
||||
return null
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
|
||||
+22
-1
@@ -10,13 +10,14 @@ import {
|
||||
GoogleSheetsIcon,
|
||||
SupabaseIcon,
|
||||
xIcon,
|
||||
AirtableIcon,
|
||||
} from '@/components/icons'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
|
||||
const logger = createLogger('OAuth')
|
||||
|
||||
// Define the base OAuth provider type
|
||||
export type OAuthProvider = 'google' | 'github' | 'x' | 'supabase' | 'confluence' | string
|
||||
export type OAuthProvider = 'google' | 'github' | 'x' | 'supabase' | 'confluence' | 'airtable' | string
|
||||
export type OAuthService =
|
||||
| 'google'
|
||||
| 'google-email'
|
||||
@@ -27,6 +28,7 @@ export type OAuthService =
|
||||
| 'x'
|
||||
| 'supabase'
|
||||
| 'confluence'
|
||||
| 'airtable'
|
||||
|
||||
// Define the interface for OAuth provider configuration
|
||||
export interface OAuthProviderConfig {
|
||||
@@ -193,6 +195,23 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
|
||||
},
|
||||
defaultService: 'confluence',
|
||||
},
|
||||
airtable: {
|
||||
id: 'airtable',
|
||||
name: 'Airtable',
|
||||
icon: (props) => AirtableIcon(props),
|
||||
services: {
|
||||
airtable: {
|
||||
id: 'airtable',
|
||||
name: 'Airtable',
|
||||
description: 'Manage Airtable bases, tables, and records.',
|
||||
providerId: 'airtable',
|
||||
icon: (props) => AirtableIcon(props),
|
||||
baseProviderIcon: (props) => AirtableIcon(props),
|
||||
scopes: ['data.records:read', 'data.records:write'],
|
||||
},
|
||||
},
|
||||
defaultService: 'airtable',
|
||||
},
|
||||
}
|
||||
|
||||
// Helper function to get a service by provider and service ID
|
||||
@@ -243,6 +262,8 @@ export function getServiceIdFromScopes(provider: OAuthProvider, scopes: string[]
|
||||
return 'x'
|
||||
} else if (provider === 'confluence') {
|
||||
return 'confluence'
|
||||
} else if (provider === 'airtable') {
|
||||
return 'airtable'
|
||||
}
|
||||
|
||||
return providerConfig.defaultService
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*
|
||||
* Airtable Tools Integration Tests
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
import { ToolTester } from '../__test-utils__/test-tools'
|
||||
import { airtableReadTool, airtableWriteTool, airtableUpdateTool } from './index'
|
||||
|
||||
describe('Airtable Tools Integration', () => {
|
||||
let tester: ToolTester
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.NEXT_PUBLIC_APP_URL = 'http://localhost:3000'
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks()
|
||||
delete process.env.NEXT_PUBLIC_APP_URL
|
||||
})
|
||||
|
||||
describe('Airtable Read Tool', () => {
|
||||
beforeEach(() => {
|
||||
tester = new ToolTester(airtableReadTool)
|
||||
})
|
||||
|
||||
test('should construct correct read request', () => {
|
||||
const params = {
|
||||
baseId: 'base123',
|
||||
tableId: 'table456',
|
||||
accessToken: 'token789',
|
||||
maxRecords: 100,
|
||||
filterFormula: "Status='Active'"
|
||||
}
|
||||
|
||||
const url = tester.getRequestUrl(params)
|
||||
const headers = tester.getRequestHeaders(params)
|
||||
|
||||
expect(url).toContain('/base123/table456')
|
||||
expect(url).toContain('maxRecords=100')
|
||||
expect(url).toContain('filterByFormula=Status%3D%27Active%27')
|
||||
expect(headers['Authorization']).toBe('Bearer token789')
|
||||
})
|
||||
|
||||
test('should handle successful read response', async () => {
|
||||
const mockData = {
|
||||
records: [
|
||||
{ id: 'rec1', fields: { Name: 'Test 1' } },
|
||||
{ id: 'rec2', fields: { Name: 'Test 2' } }
|
||||
],
|
||||
offset: 'next_page_token'
|
||||
}
|
||||
|
||||
tester.setup(mockData)
|
||||
|
||||
const result = await tester.execute({
|
||||
baseId: 'base123',
|
||||
tableId: 'table456',
|
||||
accessToken: 'token789'
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.output.records).toHaveLength(2)
|
||||
expect(result.output.metadata.offset).toBe('next_page_token')
|
||||
expect(result.output.metadata.totalRecords).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Airtable Write Tool', () => {
|
||||
beforeEach(() => {
|
||||
tester = new ToolTester(airtableWriteTool)
|
||||
})
|
||||
|
||||
test('should construct correct write request', () => {
|
||||
const params = {
|
||||
baseId: 'base123',
|
||||
tableId: 'table456',
|
||||
accessToken: 'token789',
|
||||
records: [{ fields: { Name: 'New Record' } }]
|
||||
}
|
||||
|
||||
const url = tester.getRequestUrl(params)
|
||||
const headers = tester.getRequestHeaders(params)
|
||||
const body = tester.getRequestBody(params)
|
||||
|
||||
expect(url).toContain('/base123/table456')
|
||||
expect(headers['Authorization']).toBe('Bearer token789')
|
||||
expect(body).toEqual({ records: [{ fields: { Name: 'New Record' } }] })
|
||||
})
|
||||
|
||||
test('should handle successful write response', async () => {
|
||||
const mockData = {
|
||||
records: [
|
||||
{ id: 'rec1', fields: { Name: 'New Record' } }
|
||||
]
|
||||
}
|
||||
|
||||
tester.setup(mockData)
|
||||
|
||||
const result = await tester.execute({
|
||||
baseId: 'base123',
|
||||
tableId: 'table456',
|
||||
accessToken: 'token789',
|
||||
records: [{ fields: { Name: 'New Record' } }]
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.output.records).toHaveLength(1)
|
||||
expect(result.output.metadata.recordCount).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Airtable Update Tool', () => {
|
||||
beforeEach(() => {
|
||||
tester = new ToolTester(airtableUpdateTool)
|
||||
})
|
||||
|
||||
test('should construct correct update request', () => {
|
||||
const params = {
|
||||
baseId: 'base123',
|
||||
tableId: 'table456',
|
||||
recordId: 'rec789',
|
||||
accessToken: 'token789',
|
||||
fields: { Name: 'Updated Record' }
|
||||
}
|
||||
|
||||
const url = tester.getRequestUrl(params)
|
||||
const headers = tester.getRequestHeaders(params)
|
||||
const body = tester.getRequestBody(params)
|
||||
|
||||
expect(url).toContain('/base123/table456/rec789')
|
||||
expect(headers['Authorization']).toBe('Bearer token789')
|
||||
expect(body).toEqual({ fields: { Name: 'Updated Record' } })
|
||||
})
|
||||
|
||||
test('should handle successful update response', async () => {
|
||||
const mockData = {
|
||||
id: 'rec789',
|
||||
fields: { Name: 'Updated Record' }
|
||||
}
|
||||
|
||||
tester.setup(mockData)
|
||||
|
||||
const result = await tester.execute({
|
||||
baseId: 'base123',
|
||||
tableId: 'table456',
|
||||
recordId: 'rec789',
|
||||
accessToken: 'token789',
|
||||
fields: { Name: 'Updated Record' }
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.output.records).toHaveLength(1)
|
||||
expect(result.output.metadata.recordCount).toBe(1)
|
||||
expect(result.output.metadata.updatedFields).toContain('Name')
|
||||
})
|
||||
})
|
||||
|
||||
test('should handle error responses', async () => {
|
||||
tester = new ToolTester(airtableReadTool)
|
||||
|
||||
const errorMessage = 'Invalid API key'
|
||||
tester.setup({ error: errorMessage }, { ok: false, status: 401 })
|
||||
|
||||
const result = await tester.execute({
|
||||
baseId: 'base123',
|
||||
tableId: 'table456',
|
||||
accessToken: 'invalid_token'
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('Failed to read Airtable records')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
import { readTool } from './read'
|
||||
import { writeTool } from './write'
|
||||
import { updateTool } from './update'
|
||||
|
||||
export const airtableReadTool = readTool
|
||||
export const airtableWriteTool = writeTool
|
||||
export const airtableUpdateTool = updateTool
|
||||
@@ -0,0 +1,79 @@
|
||||
import { ToolConfig } from '../types'
|
||||
import { AirtableReadParams, AirtableReadResponse } from './types'
|
||||
|
||||
export const readTool: ToolConfig<AirtableReadParams, AirtableReadResponse> = {
|
||||
id: 'airtable_read',
|
||||
name: 'Airtable Read Records',
|
||||
description: 'Read records from an Airtable table',
|
||||
version: '1.0.0',
|
||||
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'airtable',
|
||||
},
|
||||
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'OAuth access token',
|
||||
},
|
||||
baseId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'ID of the Airtable base',
|
||||
},
|
||||
tableId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'ID or name of the table',
|
||||
},
|
||||
maxRecords: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
description: 'Maximum number of records to return',
|
||||
},
|
||||
filterFormula: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: 'Formula to filter records',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
const url = `https://api.airtable.com/v0/${params.baseId}/${params.tableId}`
|
||||
const queryParams = new URLSearchParams()
|
||||
if (params.maxRecords) queryParams.append('maxRecords', params.maxRecords.toString())
|
||||
if (params.filterFormula) {
|
||||
const encodedFormula = encodeURIComponent(params.filterFormula).replace(/'/g, '%27')
|
||||
queryParams.append('filterByFormula', encodedFormula)
|
||||
}
|
||||
const queryString = queryParams.toString()
|
||||
return queryString ? `${url}?${queryString}` : url
|
||||
},
|
||||
method: 'GET',
|
||||
headers: (params) => ({
|
||||
'Authorization': `Bearer ${params.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response) => {
|
||||
const data = await response.json()
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
records: data.records,
|
||||
metadata: {
|
||||
offset: data.offset,
|
||||
totalRecords: data.records.length,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
transformError: (error) => {
|
||||
return `Failed to read Airtable records: ${error.message}`
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { ToolResponse } from '../types'
|
||||
|
||||
// Common interfaces
|
||||
interface AirtableRecord {
|
||||
id: string
|
||||
createdTime: string
|
||||
fields: Record<string, any>
|
||||
}
|
||||
|
||||
interface AirtableError {
|
||||
type: string
|
||||
message: string
|
||||
}
|
||||
|
||||
// Response interfaces
|
||||
export interface AirtableReadResponse extends ToolResponse {
|
||||
output: {
|
||||
records: AirtableRecord[]
|
||||
metadata: {
|
||||
offset?: string
|
||||
totalRecords?: number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface AirtableWriteResponse extends ToolResponse {
|
||||
output: {
|
||||
records: AirtableRecord[]
|
||||
metadata: {
|
||||
recordCount: number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface AirtableUpdateResponse extends ToolResponse {
|
||||
output: {
|
||||
records: AirtableRecord[]
|
||||
metadata: {
|
||||
recordCount: number
|
||||
updatedFields: string[]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Request interfaces
|
||||
export interface AirtableReadParams {
|
||||
accessToken: string
|
||||
baseId: string
|
||||
tableId: string
|
||||
maxRecords?: number
|
||||
filterFormula?: string
|
||||
}
|
||||
|
||||
export interface AirtableWriteParams {
|
||||
accessToken: string
|
||||
baseId: string
|
||||
tableId: string
|
||||
records: Array<{
|
||||
fields: Record<string, any>
|
||||
}>
|
||||
}
|
||||
|
||||
export interface AirtableUpdateParams {
|
||||
accessToken: string
|
||||
baseId: string
|
||||
tableId: string
|
||||
recordId: string
|
||||
fields: Record<string, any>
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { ToolConfig } from '../types'
|
||||
import { AirtableUpdateParams, AirtableUpdateResponse } from './types'
|
||||
|
||||
export const updateTool: ToolConfig<AirtableUpdateParams, AirtableUpdateResponse> = {
|
||||
id: 'airtable_update',
|
||||
name: 'Airtable Update Records',
|
||||
description: 'Update existing records in an Airtable table',
|
||||
version: '1.0.0',
|
||||
|
||||
provider: 'airtable',
|
||||
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'OAuth access token',
|
||||
},
|
||||
baseId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'ID of the Airtable base',
|
||||
},
|
||||
tableId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'ID or name of the table',
|
||||
},
|
||||
recordId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'ID of the record to update',
|
||||
},
|
||||
fields: {
|
||||
type: 'json',
|
||||
required: true,
|
||||
description: 'Fields to update',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) =>
|
||||
`https://api.airtable.com/v0/${params.baseId}/${params.tableId}/${params.recordId}`,
|
||||
method: 'PATCH',
|
||||
headers: (params) => ({
|
||||
'Authorization': `Bearer ${params.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params) => ({ fields: params.fields }),
|
||||
},
|
||||
|
||||
transformResponse: async (response) => {
|
||||
const data = await response.json()
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
records: [data],
|
||||
metadata: {
|
||||
recordCount: 1,
|
||||
updatedFields: Object.keys(data.fields),
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
transformError: (error) => {
|
||||
return `Failed to update Airtable record: ${error.message}`
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { ToolConfig } from '../types'
|
||||
import { AirtableWriteParams, AirtableWriteResponse } from './types'
|
||||
|
||||
export const writeTool: ToolConfig<AirtableWriteParams, AirtableWriteResponse> = {
|
||||
id: 'airtable_write',
|
||||
name: 'Airtable Write Records',
|
||||
description: 'Write new records to an Airtable table',
|
||||
version: '1.0.0',
|
||||
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'airtable',
|
||||
},
|
||||
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'OAuth access token',
|
||||
},
|
||||
baseId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'ID of the Airtable base',
|
||||
},
|
||||
tableId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'ID or name of the table',
|
||||
},
|
||||
records: {
|
||||
type: 'json',
|
||||
required: true,
|
||||
description: 'Array of records to create',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) =>
|
||||
`https://api.airtable.com/v0/${params.baseId}/${params.tableId}`,
|
||||
method: 'POST',
|
||||
headers: (params) => ({
|
||||
'Authorization': `Bearer ${params.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params) => ({ records: params.records }),
|
||||
},
|
||||
|
||||
transformResponse: async (response) => {
|
||||
const data = await response.json()
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
records: data.records,
|
||||
metadata: {
|
||||
recordCount: data.records.length,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
transformError: (error) => {
|
||||
return `Failed to write Airtable records: ${error.message}`
|
||||
},
|
||||
}
|
||||
@@ -44,6 +44,7 @@ import { visionTool } from './vision/vision'
|
||||
import { whatsappSendMessageTool } from './whatsapp'
|
||||
import { xReadTool, xSearchTool, xUserTool, xWriteTool } from './x'
|
||||
import { youtubeSearchTool } from './youtube/search'
|
||||
import { airtableReadTool, airtableWriteTool, airtableUpdateTool } from './airtable'
|
||||
|
||||
const logger = createLogger('Tools')
|
||||
|
||||
@@ -110,6 +111,9 @@ export const tools: Record<string, ToolConfig> = {
|
||||
confluence_list: confluenceListTool,
|
||||
confluence_update: confluenceUpdateTool,
|
||||
dalle_generate: dalleTool,
|
||||
airtable_read: airtableReadTool,
|
||||
airtable_write: airtableWriteTool,
|
||||
airtable_update: airtableUpdateTool,
|
||||
}
|
||||
|
||||
// Get a tool by its ID
|
||||
|
||||
Reference in New Issue
Block a user