Skip to content

Commit 964e4d0

Browse files
Michael McNeesclaude
authored andcommitted
feat(connector): add Airtable, PagerDuty, Twilio, and Asana connectors (#103, #99, #96, #116)
Implements eight connector actions across four services: - airtable/list, airtable/create_record - pagerduty/create_incident, pagerduty/resolve - twilio/sms, twilio/call - asana/create_task, asana/search All connectors follow established patterns: dual credential shape (map[string]string + map[string]any), url.PathEscape on user-supplied path segments, io.LimitReader on response bodies, and httptest.Server injection via baseURL for unit testing. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 0579850 commit 964e4d0

9 files changed

Lines changed: 1955 additions & 0 deletions

File tree

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
package connector
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"io"
9+
"net/http"
10+
"net/url"
11+
)
12+
13+
const airtableBaseURL = "https://api.airtable.com/v0"
14+
15+
// AirtableListConnector lists records from an Airtable table.
16+
type AirtableListConnector struct {
17+
Client *http.Client
18+
baseURL string // override for testing
19+
}
20+
21+
func (c *AirtableListConnector) apiURL(path string) string {
22+
base := c.baseURL
23+
if base == "" {
24+
base = airtableBaseURL
25+
}
26+
return base + path
27+
}
28+
29+
func (c *AirtableListConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) {
30+
token, err := extractAirtableToken(params)
31+
if err != nil {
32+
return nil, fmt.Errorf("airtable/list: %w", err)
33+
}
34+
35+
baseID, _ := params["base_id"].(string)
36+
if baseID == "" {
37+
return nil, fmt.Errorf("airtable/list: base_id is required")
38+
}
39+
tableID, _ := params["table_id"].(string)
40+
if tableID == "" {
41+
return nil, fmt.Errorf("airtable/list: table_id is required")
42+
}
43+
44+
path := fmt.Sprintf("/%s/%s", url.PathEscape(baseID), url.PathEscape(tableID))
45+
reqURL := c.apiURL(path)
46+
47+
q := url.Values{}
48+
if maxRecords, ok := extractInt(params["max_records"]); ok && maxRecords > 0 {
49+
q.Set("maxRecords", fmt.Sprintf("%d", maxRecords))
50+
}
51+
if formula, ok := params["filter_by_formula"].(string); ok && formula != "" {
52+
q.Set("filterByFormula", formula)
53+
}
54+
if view, ok := params["view"].(string); ok && view != "" {
55+
q.Set("view", view)
56+
}
57+
if cursor, ok := params["offset"].(string); ok && cursor != "" {
58+
q.Set("offset", cursor)
59+
}
60+
if encoded := q.Encode(); encoded != "" {
61+
reqURL += "?" + encoded
62+
}
63+
64+
req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil)
65+
if err != nil {
66+
return nil, fmt.Errorf("airtable/list: creating request: %w", err)
67+
}
68+
req.Header.Set("Authorization", "Bearer "+token)
69+
70+
resp, err := httpClient(c.Client).Do(req)
71+
if err != nil {
72+
return nil, fmt.Errorf("airtable/list: %w", err)
73+
}
74+
defer resp.Body.Close()
75+
76+
respBody, err := io.ReadAll(io.LimitReader(resp.Body, DefaultMaxResponseBytes))
77+
if err != nil {
78+
return nil, fmt.Errorf("airtable/list: reading response: %w", err)
79+
}
80+
81+
if resp.StatusCode != http.StatusOK {
82+
return nil, fmt.Errorf("airtable/list: Airtable API returned %d: %s", resp.StatusCode, truncate(string(respBody), 500))
83+
}
84+
85+
var result struct {
86+
Records []any `json:"records"`
87+
Offset string `json:"offset"`
88+
}
89+
if err := json.Unmarshal(respBody, &result); err != nil {
90+
return nil, fmt.Errorf("airtable/list: parsing response: %w", err)
91+
}
92+
93+
out := map[string]any{
94+
"records": result.Records,
95+
"count": len(result.Records),
96+
}
97+
if result.Offset != "" {
98+
out["offset"] = result.Offset
99+
}
100+
return out, nil
101+
}
102+
103+
// AirtableCreateRecordConnector creates a record in an Airtable table.
104+
type AirtableCreateRecordConnector struct {
105+
Client *http.Client
106+
baseURL string // override for testing
107+
}
108+
109+
func (c *AirtableCreateRecordConnector) apiURL(path string) string {
110+
base := c.baseURL
111+
if base == "" {
112+
base = airtableBaseURL
113+
}
114+
return base + path
115+
}
116+
117+
func (c *AirtableCreateRecordConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) {
118+
token, err := extractAirtableToken(params)
119+
if err != nil {
120+
return nil, fmt.Errorf("airtable/create_record: %w", err)
121+
}
122+
123+
baseID, _ := params["base_id"].(string)
124+
if baseID == "" {
125+
return nil, fmt.Errorf("airtable/create_record: base_id is required")
126+
}
127+
tableID, _ := params["table_id"].(string)
128+
if tableID == "" {
129+
return nil, fmt.Errorf("airtable/create_record: table_id is required")
130+
}
131+
132+
fields, _ := params["fields"].(map[string]any)
133+
if fields == nil {
134+
fields = map[string]any{}
135+
}
136+
137+
body := map[string]any{"fields": fields}
138+
reqJSON, err := json.Marshal(body)
139+
if err != nil {
140+
return nil, fmt.Errorf("airtable/create_record: marshaling request: %w", err)
141+
}
142+
143+
path := fmt.Sprintf("/%s/%s", url.PathEscape(baseID), url.PathEscape(tableID))
144+
req, err := http.NewRequestWithContext(ctx, "POST", c.apiURL(path), bytes.NewReader(reqJSON))
145+
if err != nil {
146+
return nil, fmt.Errorf("airtable/create_record: creating request: %w", err)
147+
}
148+
req.Header.Set("Authorization", "Bearer "+token)
149+
req.Header.Set("Content-Type", "application/json")
150+
151+
resp, err := httpClient(c.Client).Do(req)
152+
if err != nil {
153+
return nil, fmt.Errorf("airtable/create_record: %w", err)
154+
}
155+
defer resp.Body.Close()
156+
157+
respBody, err := io.ReadAll(io.LimitReader(resp.Body, DefaultMaxResponseBytes))
158+
if err != nil {
159+
return nil, fmt.Errorf("airtable/create_record: reading response: %w", err)
160+
}
161+
162+
if resp.StatusCode != http.StatusOK {
163+
return nil, fmt.Errorf("airtable/create_record: Airtable API returned %d: %s", resp.StatusCode, truncate(string(respBody), 500))
164+
}
165+
166+
var record struct {
167+
ID string `json:"id"`
168+
CreatedTime string `json:"createdTime"`
169+
Fields map[string]any `json:"fields"`
170+
}
171+
if err := json.Unmarshal(respBody, &record); err != nil {
172+
return nil, fmt.Errorf("airtable/create_record: parsing response: %w", err)
173+
}
174+
175+
return map[string]any{
176+
"id": record.ID,
177+
"created_time": record.CreatedTime,
178+
"fields": record.Fields,
179+
}, nil
180+
}
181+
182+
func extractAirtableToken(params map[string]any) (string, error) {
183+
raw, ok := params["_credential"]
184+
if !ok || raw == nil {
185+
return "", fmt.Errorf("credential is required")
186+
}
187+
delete(params, "_credential")
188+
189+
var token string
190+
switch cred := raw.(type) {
191+
case map[string]string:
192+
token = cred["token"]
193+
case map[string]any:
194+
token, _ = cred["token"].(string)
195+
default:
196+
return "", fmt.Errorf("credential is required")
197+
}
198+
if token == "" {
199+
return "", fmt.Errorf("credential must contain a 'token' field")
200+
}
201+
return token, nil
202+
}

0 commit comments

Comments
 (0)