diff --git a/packages/engine/internal/connector/airtable.go b/packages/engine/internal/connector/airtable.go new file mode 100644 index 0000000..d79c83b --- /dev/null +++ b/packages/engine/internal/connector/airtable.go @@ -0,0 +1,202 @@ +package connector + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" +) + +const airtableBaseURL = "https://api.airtable.com/v0" + +// AirtableListConnector lists records from an Airtable table. +type AirtableListConnector struct { + Client *http.Client + baseURL string // override for testing +} + +func (c *AirtableListConnector) apiURL(path string) string { + base := c.baseURL + if base == "" { + base = airtableBaseURL + } + return base + path +} + +func (c *AirtableListConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) { + token, err := extractAirtableToken(params) + if err != nil { + return nil, fmt.Errorf("airtable/list: %w", err) + } + + baseID, _ := params["base_id"].(string) + if baseID == "" { + return nil, fmt.Errorf("airtable/list: base_id is required") + } + tableID, _ := params["table_id"].(string) + if tableID == "" { + return nil, fmt.Errorf("airtable/list: table_id is required") + } + + path := fmt.Sprintf("/%s/%s", url.PathEscape(baseID), url.PathEscape(tableID)) + reqURL := c.apiURL(path) + + q := url.Values{} + if maxRecords, ok := extractInt(params["max_records"]); ok && maxRecords > 0 { + q.Set("maxRecords", fmt.Sprintf("%d", maxRecords)) + } + if formula, ok := params["filter_by_formula"].(string); ok && formula != "" { + q.Set("filterByFormula", formula) + } + if view, ok := params["view"].(string); ok && view != "" { + q.Set("view", view) + } + if cursor, ok := params["offset"].(string); ok && cursor != "" { + q.Set("offset", cursor) + } + if encoded := q.Encode(); encoded != "" { + reqURL += "?" + encoded + } + + req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) + if err != nil { + return nil, fmt.Errorf("airtable/list: creating request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := httpClient(c.Client).Do(req) + if err != nil { + return nil, fmt.Errorf("airtable/list: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(io.LimitReader(resp.Body, DefaultMaxResponseBytes)) + if err != nil { + return nil, fmt.Errorf("airtable/list: reading response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("airtable/list: Airtable API returned %d: %s", resp.StatusCode, truncate(string(respBody), 500)) + } + + var result struct { + Records []any `json:"records"` + Offset string `json:"offset"` + } + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("airtable/list: parsing response: %w", err) + } + + out := map[string]any{ + "records": result.Records, + "count": len(result.Records), + } + if result.Offset != "" { + out["offset"] = result.Offset + } + return out, nil +} + +// AirtableCreateRecordConnector creates a record in an Airtable table. +type AirtableCreateRecordConnector struct { + Client *http.Client + baseURL string // override for testing +} + +func (c *AirtableCreateRecordConnector) apiURL(path string) string { + base := c.baseURL + if base == "" { + base = airtableBaseURL + } + return base + path +} + +func (c *AirtableCreateRecordConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) { + token, err := extractAirtableToken(params) + if err != nil { + return nil, fmt.Errorf("airtable/create_record: %w", err) + } + + baseID, _ := params["base_id"].(string) + if baseID == "" { + return nil, fmt.Errorf("airtable/create_record: base_id is required") + } + tableID, _ := params["table_id"].(string) + if tableID == "" { + return nil, fmt.Errorf("airtable/create_record: table_id is required") + } + + fields, _ := params["fields"].(map[string]any) + if fields == nil { + fields = map[string]any{} + } + + body := map[string]any{"fields": fields} + reqJSON, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("airtable/create_record: marshaling request: %w", err) + } + + path := fmt.Sprintf("/%s/%s", url.PathEscape(baseID), url.PathEscape(tableID)) + req, err := http.NewRequestWithContext(ctx, "POST", c.apiURL(path), bytes.NewReader(reqJSON)) + if err != nil { + return nil, fmt.Errorf("airtable/create_record: creating request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + resp, err := httpClient(c.Client).Do(req) + if err != nil { + return nil, fmt.Errorf("airtable/create_record: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(io.LimitReader(resp.Body, DefaultMaxResponseBytes)) + if err != nil { + return nil, fmt.Errorf("airtable/create_record: reading response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("airtable/create_record: Airtable API returned %d: %s", resp.StatusCode, truncate(string(respBody), 500)) + } + + var record struct { + ID string `json:"id"` + CreatedTime string `json:"createdTime"` + Fields map[string]any `json:"fields"` + } + if err := json.Unmarshal(respBody, &record); err != nil { + return nil, fmt.Errorf("airtable/create_record: parsing response: %w", err) + } + + return map[string]any{ + "id": record.ID, + "created_time": record.CreatedTime, + "fields": record.Fields, + }, nil +} + +func extractAirtableToken(params map[string]any) (string, error) { + raw, ok := params["_credential"] + if !ok || raw == nil { + return "", fmt.Errorf("credential is required") + } + delete(params, "_credential") + + var token string + switch cred := raw.(type) { + case map[string]string: + token = cred["token"] + case map[string]any: + token, _ = cred["token"].(string) + default: + return "", fmt.Errorf("credential is required") + } + if token == "" { + return "", fmt.Errorf("credential must contain a 'token' field") + } + return token, nil +} diff --git a/packages/engine/internal/connector/airtable_test.go b/packages/engine/internal/connector/airtable_test.go new file mode 100644 index 0000000..eaa6c1b --- /dev/null +++ b/packages/engine/internal/connector/airtable_test.go @@ -0,0 +1,258 @@ +package connector + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestAirtableListConnector_ListsRecords(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + t.Errorf("expected GET, got %s", r.Method) + } + if r.Header.Get("Authorization") != "Bearer pat123" { + t.Errorf("unexpected Authorization header: %s", r.Header.Get("Authorization")) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "records": []any{ + map[string]any{"id": "rec1", "fields": map[string]any{"Name": "Task 1"}}, + map[string]any{"id": "rec2", "fields": map[string]any{"Name": "Task 2"}}, + }, + }) + })) + defer srv.Close() + + c := &AirtableListConnector{baseURL: srv.URL} + out, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{"token": "pat123"}, + "base_id": "appXXX", + "table_id": "tblYYY", + }) + if err != nil { + t.Fatal(err) + } + if out["count"].(int) != 2 { + t.Errorf("expected count=2, got %v", out["count"]) + } +} + +func TestAirtableListConnector_WithQueryParams(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + if q.Get("maxRecords") != "10" { + t.Errorf("expected maxRecords=10, got %s", q.Get("maxRecords")) + } + if q.Get("filterByFormula") != "NOT({Done})" { + t.Errorf("unexpected filterByFormula: %s", q.Get("filterByFormula")) + } + if q.Get("view") != "Grid" { + t.Errorf("unexpected view: %s", q.Get("view")) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{"records": []any{}}) + })) + defer srv.Close() + + c := &AirtableListConnector{baseURL: srv.URL} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{"token": "pat123"}, + "base_id": "appXXX", + "table_id": "tblYYY", + "max_records": 10, + "filter_by_formula": "NOT({Done})", + "view": "Grid", + }) + if err != nil { + t.Fatal(err) + } +} + +func TestAirtableListConnector_HasMore_ReturnsOffset(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "records": []any{map[string]any{"id": "rec1"}}, + "offset": "itrABC", + }) + })) + defer srv.Close() + + c := &AirtableListConnector{baseURL: srv.URL} + out, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{"token": "pat123"}, + "base_id": "appXXX", + "table_id": "tblYYY", + }) + if err != nil { + t.Fatal(err) + } + if out["offset"] != "itrABC" { + t.Errorf("expected offset=itrABC, got %v", out["offset"]) + } +} + +func TestAirtableListConnector_MissingBaseID(t *testing.T) { + c := &AirtableListConnector{baseURL: "http://unused"} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{"token": "pat123"}, + "table_id": "tblYYY", + }) + if err == nil { + t.Fatal("expected error for missing base_id") + } +} + +func TestAirtableListConnector_MissingCredential(t *testing.T) { + c := &AirtableListConnector{baseURL: "http://unused"} + _, err := c.Execute(t.Context(), map[string]any{ + "base_id": "appXXX", + "table_id": "tblYYY", + }) + if err == nil { + t.Fatal("expected error for missing credential") + } +} + +func TestAirtableListConnector_APIError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"error":{"type":"NOT_FOUND"}}`, http.StatusNotFound) + })) + defer srv.Close() + + c := &AirtableListConnector{baseURL: srv.URL} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{"token": "pat123"}, + "base_id": "appXXX", + "table_id": "tblYYY", + }) + if err == nil { + t.Fatal("expected error for 404") + } +} + +func TestAirtableCreateRecordConnector_CreatesRecord(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Errorf("expected POST, got %s", r.Method) + } + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + fields, _ := body["fields"].(map[string]any) + if fields["Name"] != "My Record" { + t.Errorf("unexpected fields: %v", fields) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "id": "recABC", + "createdTime": "2024-01-01T00:00:00.000Z", + "fields": map[string]any{"Name": "My Record"}, + }) + })) + defer srv.Close() + + c := &AirtableCreateRecordConnector{baseURL: srv.URL} + out, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{"token": "pat123"}, + "base_id": "appXXX", + "table_id": "tblYYY", + "fields": map[string]any{"Name": "My Record"}, + }) + if err != nil { + t.Fatal(err) + } + if out["id"] != "recABC" { + t.Errorf("expected id=recABC, got %v", out["id"]) + } +} + +func TestAirtableCreateRecordConnector_EmptyFields(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + fields, _ := body["fields"].(map[string]any) + if fields == nil { + t.Error("expected empty fields map, got nil") + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "id": "recDEF", + "createdTime": "2024-01-01T00:00:00.000Z", + "fields": map[string]any{}, + }) + })) + defer srv.Close() + + c := &AirtableCreateRecordConnector{baseURL: srv.URL} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{"token": "pat123"}, + "base_id": "appXXX", + "table_id": "tblYYY", + }) + if err != nil { + t.Fatal(err) + } +} + +func TestAirtableCreateRecordConnector_MissingTableID(t *testing.T) { + c := &AirtableCreateRecordConnector{baseURL: "http://unused"} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{"token": "pat123"}, + "base_id": "appXXX", + }) + if err == nil { + t.Fatal("expected error for missing table_id") + } +} + +func TestAirtableCreateRecordConnector_APIError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"error":{"type":"INVALID_REQUEST_BODY"}}`, http.StatusUnprocessableEntity) + })) + defer srv.Close() + + c := &AirtableCreateRecordConnector{baseURL: srv.URL} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{"token": "pat123"}, + "base_id": "appXXX", + "table_id": "tblYYY", + }) + if err == nil { + t.Fatal("expected error for 422") + } +} + +func TestAirtableListConnector_MapAnyCredential(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer mapany" { + t.Errorf("unexpected auth: %s", r.Header.Get("Authorization")) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{"records": []any{}}) + })) + defer srv.Close() + + c := &AirtableListConnector{baseURL: srv.URL} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]any{"token": "mapany"}, + "base_id": "appXXX", + "table_id": "tblYYY", + }) + if err != nil { + t.Fatal(err) + } +} + +func TestRegistry_AirtableConnectors(t *testing.T) { + r := NewRegistry() + if _, err := r.Get("airtable/list"); err != nil { + t.Errorf("airtable/list not registered: %v", err) + } + if _, err := r.Get("airtable/create_record"); err != nil { + t.Errorf("airtable/create_record not registered: %v", err) + } +} diff --git a/packages/engine/internal/connector/asana.go b/packages/engine/internal/connector/asana.go new file mode 100644 index 0000000..2c69b7e --- /dev/null +++ b/packages/engine/internal/connector/asana.go @@ -0,0 +1,211 @@ +package connector + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" +) + +const asanaBaseURL = "https://app.asana.com/api/1.0" + +// AsanaCreateTaskConnector creates a task in Asana. +type AsanaCreateTaskConnector struct { + Client *http.Client + baseURL string // override for testing +} + +func (c *AsanaCreateTaskConnector) apiURL(path string) string { + base := c.baseURL + if base == "" { + base = asanaBaseURL + } + return base + path +} + +func (c *AsanaCreateTaskConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) { + token, err := extractAsanaToken(params) + if err != nil { + return nil, fmt.Errorf("asana/create_task: %w", err) + } + + name, _ := params["name"].(string) + if name == "" { + return nil, fmt.Errorf("asana/create_task: name is required") + } + + workspace, _ := params["workspace"].(string) + projects := toStringSlice(params["projects"]) + if workspace == "" && len(projects) == 0 { + return nil, fmt.Errorf("asana/create_task: workspace or projects is required") + } + + task := map[string]any{"name": name} + if workspace != "" { + task["workspace"] = workspace + } + if len(projects) > 0 { + task["projects"] = projects + } + if notes, ok := params["notes"].(string); ok && notes != "" { + task["notes"] = notes + } + if assignee, ok := params["assignee"].(string); ok && assignee != "" { + task["assignee"] = assignee + } + if dueOn, ok := params["due_on"].(string); ok && dueOn != "" { + task["due_on"] = dueOn + } + + reqJSON, err := json.Marshal(map[string]any{"data": task}) + if err != nil { + return nil, fmt.Errorf("asana/create_task: marshaling request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", c.apiURL("/tasks"), bytes.NewReader(reqJSON)) + if err != nil { + return nil, fmt.Errorf("asana/create_task: creating request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + resp, err := httpClient(c.Client).Do(req) + if err != nil { + return nil, fmt.Errorf("asana/create_task: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(io.LimitReader(resp.Body, DefaultMaxResponseBytes)) + if err != nil { + return nil, fmt.Errorf("asana/create_task: reading response: %w", err) + } + + if resp.StatusCode != http.StatusCreated { + return nil, fmt.Errorf("asana/create_task: Asana API returned %d: %s", resp.StatusCode, truncate(string(respBody), 500)) + } + + var result struct { + Data struct { + GID string `json:"gid"` + Name string `json:"name"` + PermalinkURL string `json:"permalink_url"` + } `json:"data"` + } + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("asana/create_task: parsing response: %w", err) + } + + return map[string]any{ + "gid": result.Data.GID, + "name": result.Data.Name, + "permalink_url": result.Data.PermalinkURL, + }, nil +} + +// AsanaSearchConnector searches tasks within an Asana workspace. +type AsanaSearchConnector struct { + Client *http.Client + baseURL string // override for testing +} + +func (c *AsanaSearchConnector) apiURL(path string) string { + base := c.baseURL + if base == "" { + base = asanaBaseURL + } + return base + path +} + +func (c *AsanaSearchConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) { + token, err := extractAsanaToken(params) + if err != nil { + return nil, fmt.Errorf("asana/search: %w", err) + } + + workspaceGID, _ := params["workspace"].(string) + if workspaceGID == "" { + return nil, fmt.Errorf("asana/search: workspace is required") + } + + q := url.Values{} + if text, ok := params["text"].(string); ok && text != "" { + q.Set("text", text) + } + if assignee, ok := params["assignee"].(string); ok && assignee != "" { + q.Set("assignee.any", assignee) + } + if completed, ok := params["completed"].(bool); ok { + if completed { + q.Set("completed", "true") + } else { + q.Set("completed", "false") + } + } + if limit, ok := extractInt(params["limit"]); ok && limit > 0 { + q.Set("limit", fmt.Sprintf("%d", limit)) + } + + path := fmt.Sprintf("/workspaces/%s/tasks/search", url.PathEscape(workspaceGID)) + reqURL := c.apiURL(path) + if encoded := q.Encode(); encoded != "" { + reqURL += "?" + encoded + } + + req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) + if err != nil { + return nil, fmt.Errorf("asana/search: creating request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := httpClient(c.Client).Do(req) + if err != nil { + return nil, fmt.Errorf("asana/search: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(io.LimitReader(resp.Body, DefaultMaxResponseBytes)) + if err != nil { + return nil, fmt.Errorf("asana/search: reading response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("asana/search: Asana API returned %d: %s", resp.StatusCode, truncate(string(respBody), 500)) + } + + var result struct { + Data []any `json:"data"` + } + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("asana/search: parsing response: %w", err) + } + + return map[string]any{ + "tasks": result.Data, + "count": len(result.Data), + }, nil +} + +func extractAsanaToken(params map[string]any) (string, error) { + raw, ok := params["_credential"] + if !ok || raw == nil { + return "", fmt.Errorf("credential is required") + } + delete(params, "_credential") + + var token string + switch cred := raw.(type) { + case map[string]string: + token = cred["token"] + case map[string]any: + token, _ = cred["token"].(string) + default: + return "", fmt.Errorf("credential is required") + } + if token == "" { + return "", fmt.Errorf("credential must contain a 'token' field") + } + return token, nil +} diff --git a/packages/engine/internal/connector/asana_test.go b/packages/engine/internal/connector/asana_test.go new file mode 100644 index 0000000..3b841cd --- /dev/null +++ b/packages/engine/internal/connector/asana_test.go @@ -0,0 +1,331 @@ +package connector + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestAsanaCreateTaskConnector_CreatesTask(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" || r.URL.Path != "/tasks" { + t.Errorf("unexpected %s %s", r.Method, r.URL.Path) + } + if r.Header.Get("Authorization") != "Bearer asana-token" { + t.Errorf("unexpected Authorization: %s", r.Header.Get("Authorization")) + } + + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + data := body["data"].(map[string]any) + if data["name"] != "Fix the bug" { + t.Errorf("unexpected name: %v", data["name"]) + } + if data["workspace"] != "12345" { + t.Errorf("unexpected workspace: %v", data["workspace"]) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "gid": "67890", + "name": "Fix the bug", + "permalink_url": "https://app.asana.com/0/0/67890", + }, + }) + })) + defer srv.Close() + + c := &AsanaCreateTaskConnector{baseURL: srv.URL} + out, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{"token": "asana-token"}, + "name": "Fix the bug", + "workspace": "12345", + }) + if err != nil { + t.Fatal(err) + } + if out["gid"] != "67890" { + t.Errorf("expected gid=67890, got %v", out["gid"]) + } + if out["permalink_url"] != "https://app.asana.com/0/0/67890" { + t.Errorf("unexpected permalink_url: %v", out["permalink_url"]) + } +} + +func TestAsanaCreateTaskConnector_WithOptionalFields(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + data := body["data"].(map[string]any) + if data["notes"] != "Details here" { + t.Errorf("unexpected notes: %v", data["notes"]) + } + if data["assignee"] != "me" { + t.Errorf("unexpected assignee: %v", data["assignee"]) + } + if data["due_on"] != "2024-12-31" { + t.Errorf("unexpected due_on: %v", data["due_on"]) + } + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{"gid": "1", "name": "t", "permalink_url": ""}, + }) + })) + defer srv.Close() + + c := &AsanaCreateTaskConnector{baseURL: srv.URL} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{"token": "asana-token"}, + "name": "t", + "workspace": "12345", + "notes": "Details here", + "assignee": "me", + "due_on": "2024-12-31", + }) + if err != nil { + t.Fatal(err) + } +} + +func TestAsanaCreateTaskConnector_WithProjects(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + data := body["data"].(map[string]any) + // JSON round-trip produces []any from []string in the serialized body. + projects, _ := data["projects"].([]any) + if len(projects) != 1 || projects[0] != "PROJ123" { + t.Errorf("unexpected projects: %v", projects) + } + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{"gid": "1", "name": "t", "permalink_url": ""}, + }) + })) + defer srv.Close() + + c := &AsanaCreateTaskConnector{baseURL: srv.URL} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{"token": "asana-token"}, + "name": "t", + "projects": []any{"PROJ123"}, + }) + if err != nil { + t.Fatal(err) + } +} + +func TestAsanaCreateTaskConnector_ProjectsAsStringSlice(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + data := body["data"].(map[string]any) + projects, _ := data["projects"].([]any) + if len(projects) != 1 || projects[0] != "PROJ456" { + t.Errorf("unexpected projects: %v", projects) + } + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{"gid": "1", "name": "t", "permalink_url": ""}, + }) + })) + defer srv.Close() + + c := &AsanaCreateTaskConnector{baseURL: srv.URL} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{"token": "asana-token"}, + "name": "t", + "projects": []string{"PROJ456"}, // native []string, not []any + }) + if err != nil { + t.Fatal(err) + } +} + +func TestAsanaCreateTaskConnector_MissingName(t *testing.T) { + c := &AsanaCreateTaskConnector{baseURL: "http://unused"} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{"token": "asana-token"}, + "workspace": "12345", + }) + if err == nil { + t.Fatal("expected error for missing name") + } +} + +func TestAsanaCreateTaskConnector_MissingWorkspaceAndProjects(t *testing.T) { + c := &AsanaCreateTaskConnector{baseURL: "http://unused"} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{"token": "asana-token"}, + "name": "t", + }) + if err == nil { + t.Fatal("expected error when neither workspace nor projects provided") + } +} + +func TestAsanaCreateTaskConnector_MissingCredential(t *testing.T) { + c := &AsanaCreateTaskConnector{baseURL: "http://unused"} + _, err := c.Execute(t.Context(), map[string]any{ + "name": "t", + "workspace": "12345", + }) + if err == nil { + t.Fatal("expected error for missing credential") + } +} + +func TestAsanaCreateTaskConnector_APIError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"errors":[{"message":"Invalid token"}]}`, http.StatusUnauthorized) + })) + defer srv.Close() + + c := &AsanaCreateTaskConnector{baseURL: srv.URL} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{"token": "bad"}, + "name": "t", + "workspace": "12345", + }) + if err == nil { + t.Fatal("expected error for 401") + } +} + +func TestAsanaSearchConnector_SearchesTasks(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/workspaces/WS123/tasks/search" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + if r.URL.Query().Get("text") != "bug" { + t.Errorf("unexpected text param: %s", r.URL.Query().Get("text")) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "data": []any{ + map[string]any{"gid": "1", "name": "Bug fix"}, + map[string]any{"gid": "2", "name": "Bug report"}, + }, + }) + })) + defer srv.Close() + + c := &AsanaSearchConnector{baseURL: srv.URL} + out, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{"token": "asana-token"}, + "workspace": "WS123", + "text": "bug", + }) + if err != nil { + t.Fatal(err) + } + if out["count"].(int) != 2 { + t.Errorf("expected count=2, got %v", out["count"]) + } +} + +func TestAsanaSearchConnector_WithFilters(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + if q.Get("assignee.any") != "me" { + t.Errorf("unexpected assignee.any: %s", q.Get("assignee.any")) + } + if q.Get("completed") != "false" { + t.Errorf("unexpected completed: %s", q.Get("completed")) + } + if q.Get("limit") != "20" { + t.Errorf("unexpected limit: %s", q.Get("limit")) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{"data": []any{}}) + })) + defer srv.Close() + + c := &AsanaSearchConnector{baseURL: srv.URL} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{"token": "asana-token"}, + "workspace": "WS123", + "assignee": "me", + "completed": false, + "limit": 20, + }) + if err != nil { + t.Fatal(err) + } +} + +func TestAsanaSearchConnector_MissingWorkspace(t *testing.T) { + c := &AsanaSearchConnector{baseURL: "http://unused"} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{"token": "asana-token"}, + "text": "bug", + }) + if err == nil { + t.Fatal("expected error for missing workspace") + } +} + +func TestAsanaSearchConnector_MissingCredential(t *testing.T) { + c := &AsanaSearchConnector{baseURL: "http://unused"} + _, err := c.Execute(t.Context(), map[string]any{ + "workspace": "WS123", + }) + if err == nil { + t.Fatal("expected error for missing credential") + } +} + +func TestAsanaSearchConnector_APIError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"errors":[{"message":"Forbidden"}]}`, http.StatusForbidden) + })) + defer srv.Close() + + c := &AsanaSearchConnector{baseURL: srv.URL} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{"token": "asana-token"}, + "workspace": "WS123", + }) + if err == nil { + t.Fatal("expected error for 403") + } +} + +func TestAsanaCreateTaskConnector_MapAnyCredential(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer mapany" { + t.Errorf("unexpected auth: %s", r.Header.Get("Authorization")) + } + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{"gid": "1", "name": "t", "permalink_url": ""}, + }) + })) + defer srv.Close() + + c := &AsanaCreateTaskConnector{baseURL: srv.URL} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]any{"token": "mapany"}, + "name": "t", + "workspace": "12345", + }) + if err != nil { + t.Fatal(err) + } +} + +func TestRegistry_AsanaConnectors(t *testing.T) { + r := NewRegistry() + if _, err := r.Get("asana/create_task"); err != nil { + t.Errorf("asana/create_task not registered: %v", err) + } + if _, err := r.Get("asana/search"); err != nil { + t.Errorf("asana/search not registered: %v", err) + } +} diff --git a/packages/engine/internal/connector/connector.go b/packages/engine/internal/connector/connector.go index 4148525..196632a 100644 --- a/packages/engine/internal/connector/connector.go +++ b/packages/engine/internal/connector/connector.go @@ -46,6 +46,14 @@ func NewRegistry() *Registry { r.Register("linear/search", &LinearSearchConnector{}) r.Register("notion/create_page", &NotionCreatePageConnector{}) r.Register("notion/query_database", &NotionQueryDatabaseConnector{}) + r.Register("airtable/list", &AirtableListConnector{}) + r.Register("airtable/create_record", &AirtableCreateRecordConnector{}) + r.Register("pagerduty/create_incident", &PagerDutyCreateIncidentConnector{}) + r.Register("pagerduty/resolve", &PagerDutyResolveConnector{}) + r.Register("twilio/sms", &TwilioSMSConnector{}) + r.Register("twilio/call", &TwilioCallConnector{}) + r.Register("asana/create_task", &AsanaCreateTaskConnector{}) + r.Register("asana/search", &AsanaSearchConnector{}) return r } diff --git a/packages/engine/internal/connector/pagerduty.go b/packages/engine/internal/connector/pagerduty.go new file mode 100644 index 0000000..b9e6233 --- /dev/null +++ b/packages/engine/internal/connector/pagerduty.go @@ -0,0 +1,225 @@ +package connector + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" +) + +const pagerdutyBaseURL = "https://api.pagerduty.com" + +// PagerDutyCreateIncidentConnector creates a PagerDuty incident. +type PagerDutyCreateIncidentConnector struct { + Client *http.Client + baseURL string // override for testing +} + +func (c *PagerDutyCreateIncidentConnector) apiURL(path string) string { + base := c.baseURL + if base == "" { + base = pagerdutyBaseURL + } + return base + path +} + +func (c *PagerDutyCreateIncidentConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) { + token, fromEmail, err := extractPagerDutyCredential(params) + if err != nil { + return nil, fmt.Errorf("pagerduty/create_incident: %w", err) + } + + title, _ := params["title"].(string) + if title == "" { + return nil, fmt.Errorf("pagerduty/create_incident: title is required") + } + serviceID, _ := params["service_id"].(string) + if serviceID == "" { + return nil, fmt.Errorf("pagerduty/create_incident: service_id is required") + } + + incident := map[string]any{ + "type": "incident", + "title": title, + "service": map[string]any{"id": serviceID, "type": "service_reference"}, + } + if urgency, ok := params["urgency"].(string); ok && urgency != "" { + incident["urgency"] = urgency + } + if details, ok := params["details"].(string); ok && details != "" { + incident["body"] = map[string]any{ + "type": "incident_body", + "details": details, + } + } + + body := map[string]any{"incident": incident} + reqJSON, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("pagerduty/create_incident: marshaling request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", c.apiURL("/incidents"), bytes.NewReader(reqJSON)) + if err != nil { + return nil, fmt.Errorf("pagerduty/create_incident: creating request: %w", err) + } + req.Header.Set("Authorization", "Token token="+token) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/vnd.pagerduty+json;version=2") + if fromEmail != "" { + req.Header.Set("From", fromEmail) + } else if fe, ok := params["from_email"].(string); ok && fe != "" { + req.Header.Set("From", fe) + } + + resp, err := httpClient(c.Client).Do(req) + if err != nil { + return nil, fmt.Errorf("pagerduty/create_incident: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(io.LimitReader(resp.Body, DefaultMaxResponseBytes)) + if err != nil { + return nil, fmt.Errorf("pagerduty/create_incident: reading response: %w", err) + } + + if resp.StatusCode != http.StatusCreated { + return nil, fmt.Errorf("pagerduty/create_incident: PagerDuty API returned %d: %s", resp.StatusCode, truncate(string(respBody), 500)) + } + + var result struct { + Incident struct { + ID string `json:"id"` + IncidentNumber int `json:"incident_number"` + Title string `json:"title"` + Status string `json:"status"` + Urgency string `json:"urgency"` + HTMLURL string `json:"html_url"` + } `json:"incident"` + } + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("pagerduty/create_incident: parsing response: %w", err) + } + + return map[string]any{ + "id": result.Incident.ID, + "incident_number": result.Incident.IncidentNumber, + "title": result.Incident.Title, + "status": result.Incident.Status, + "urgency": result.Incident.Urgency, + "html_url": result.Incident.HTMLURL, + }, nil +} + +// PagerDutyResolveConnector resolves a PagerDuty incident. +type PagerDutyResolveConnector struct { + Client *http.Client + baseURL string // override for testing +} + +func (c *PagerDutyResolveConnector) apiURL(path string) string { + base := c.baseURL + if base == "" { + base = pagerdutyBaseURL + } + return base + path +} + +func (c *PagerDutyResolveConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) { + token, fromEmail, err := extractPagerDutyCredential(params) + if err != nil { + return nil, fmt.Errorf("pagerduty/resolve: %w", err) + } + + incidentID, _ := params["incident_id"].(string) + if incidentID == "" { + return nil, fmt.Errorf("pagerduty/resolve: incident_id is required") + } + + // from_email is required for REST API key auth: PagerDuty rejects incident + // updates without a From header when using non-user-context credentials. + if fromEmail == "" { + fromEmail, _ = params["from_email"].(string) + } + if fromEmail == "" { + return nil, fmt.Errorf("pagerduty/resolve: from_email is required (set in credential or as param)") + } + + body := map[string]any{ + "incident": map[string]any{ + "type": "incident", + "status": "resolved", + }, + } + reqJSON, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("pagerduty/resolve: marshaling request: %w", err) + } + + path := fmt.Sprintf("/incidents/%s", url.PathEscape(incidentID)) + req, err := http.NewRequestWithContext(ctx, "PUT", c.apiURL(path), bytes.NewReader(reqJSON)) + if err != nil { + return nil, fmt.Errorf("pagerduty/resolve: creating request: %w", err) + } + req.Header.Set("Authorization", "Token token="+token) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/vnd.pagerduty+json;version=2") + req.Header.Set("From", fromEmail) + + resp, err := httpClient(c.Client).Do(req) + if err != nil { + return nil, fmt.Errorf("pagerduty/resolve: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(io.LimitReader(resp.Body, DefaultMaxResponseBytes)) + if err != nil { + return nil, fmt.Errorf("pagerduty/resolve: reading response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("pagerduty/resolve: PagerDuty API returned %d: %s", resp.StatusCode, truncate(string(respBody), 500)) + } + + var result struct { + Incident struct { + ID string `json:"id"` + Status string `json:"status"` + } `json:"incident"` + } + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("pagerduty/resolve: parsing response: %w", err) + } + + return map[string]any{ + "id": result.Incident.ID, + "status": result.Incident.Status, + }, nil +} + +// extractPagerDutyCredential extracts the API token and optional from_email from _credential. +func extractPagerDutyCredential(params map[string]any) (token, fromEmail string, err error) { + raw, ok := params["_credential"] + if !ok || raw == nil { + return "", "", fmt.Errorf("credential is required") + } + delete(params, "_credential") + + switch cred := raw.(type) { + case map[string]string: + token = cred["token"] + fromEmail = cred["from_email"] + case map[string]any: + token, _ = cred["token"].(string) + fromEmail, _ = cred["from_email"].(string) + default: + return "", "", fmt.Errorf("credential is required") + } + if token == "" { + return "", "", fmt.Errorf("credential must contain a 'token' field") + } + return token, fromEmail, nil +} diff --git a/packages/engine/internal/connector/pagerduty_test.go b/packages/engine/internal/connector/pagerduty_test.go new file mode 100644 index 0000000..074c73f --- /dev/null +++ b/packages/engine/internal/connector/pagerduty_test.go @@ -0,0 +1,310 @@ +package connector + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestPagerDutyCreateIncidentConnector_CreatesIncident(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" || r.URL.Path != "/incidents" { + t.Errorf("unexpected %s %s", r.Method, r.URL.Path) + } + if r.Header.Get("Authorization") != "Token token=u+abc" { + t.Errorf("unexpected Authorization: %s", r.Header.Get("Authorization")) + } + if r.Header.Get("Accept") != "application/vnd.pagerduty+json;version=2" { + t.Errorf("unexpected Accept: %s", r.Header.Get("Accept")) + } + + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + inc := body["incident"].(map[string]any) + if inc["title"] != "DB is down" { + t.Errorf("unexpected title: %v", inc["title"]) + } + svc := inc["service"].(map[string]any) + if svc["id"] != "PABC123" { + t.Errorf("unexpected service id: %v", svc["id"]) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]any{ + "incident": map[string]any{ + "id": "Q2AVLPZB5RX", + "incident_number": 42, + "title": "DB is down", + "status": "triggered", + "urgency": "high", + "html_url": "https://app.pagerduty.com/incidents/Q2AVLPZB5RX", + }, + }) + })) + defer srv.Close() + + c := &PagerDutyCreateIncidentConnector{baseURL: srv.URL} + out, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{"token": "u+abc"}, + "title": "DB is down", + "service_id": "PABC123", + }) + if err != nil { + t.Fatal(err) + } + if out["id"] != "Q2AVLPZB5RX" { + t.Errorf("expected id=Q2AVLPZB5RX, got %v", out["id"]) + } + if out["incident_number"].(int) != 42 { + t.Errorf("expected incident_number=42, got %v", out["incident_number"]) + } + if out["status"] != "triggered" { + t.Errorf("expected status=triggered, got %v", out["status"]) + } +} + +func TestPagerDutyCreateIncidentConnector_WithDetails(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + inc := body["incident"].(map[string]any) + incBody, _ := inc["body"].(map[string]any) + if incBody["details"] != "Some details" { + t.Errorf("unexpected details: %v", incBody["details"]) + } + if inc["urgency"] != "low" { + t.Errorf("unexpected urgency: %v", inc["urgency"]) + } + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]any{ + "incident": map[string]any{ + "id": "QABC", "incident_number": 1, + "title": "t", "status": "triggered", "urgency": "low", "html_url": "", + }, + }) + })) + defer srv.Close() + + c := &PagerDutyCreateIncidentConnector{baseURL: srv.URL} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{"token": "u+abc"}, + "title": "t", + "service_id": "SVC1", + "urgency": "low", + "details": "Some details", + }) + if err != nil { + t.Fatal(err) + } +} + +func TestPagerDutyCreateIncidentConnector_FromEmailInCredential(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("From") != "oncall@example.com" { + t.Errorf("unexpected From header: %s", r.Header.Get("From")) + } + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]any{ + "incident": map[string]any{ + "id": "Q1", "incident_number": 1, + "title": "t", "status": "triggered", "urgency": "high", "html_url": "", + }, + }) + })) + defer srv.Close() + + c := &PagerDutyCreateIncidentConnector{baseURL: srv.URL} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{"token": "u+abc", "from_email": "oncall@example.com"}, + "title": "t", + "service_id": "SVC1", + }) + if err != nil { + t.Fatal(err) + } +} + +func TestPagerDutyCreateIncidentConnector_MissingTitle(t *testing.T) { + c := &PagerDutyCreateIncidentConnector{baseURL: "http://unused"} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{"token": "u+abc"}, + "service_id": "SVC1", + }) + if err == nil { + t.Fatal("expected error for missing title") + } +} + +func TestPagerDutyCreateIncidentConnector_MissingServiceID(t *testing.T) { + c := &PagerDutyCreateIncidentConnector{baseURL: "http://unused"} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{"token": "u+abc"}, + "title": "t", + }) + if err == nil { + t.Fatal("expected error for missing service_id") + } +} + +func TestPagerDutyCreateIncidentConnector_MissingCredential(t *testing.T) { + c := &PagerDutyCreateIncidentConnector{baseURL: "http://unused"} + _, err := c.Execute(t.Context(), map[string]any{ + "title": "t", + "service_id": "SVC1", + }) + if err == nil { + t.Fatal("expected error for missing credential") + } +} + +func TestPagerDutyCreateIncidentConnector_APIError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"error":{"message":"Forbidden"}}`, http.StatusForbidden) + })) + defer srv.Close() + + c := &PagerDutyCreateIncidentConnector{baseURL: srv.URL} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{"token": "u+abc"}, + "title": "t", + "service_id": "SVC1", + }) + if err == nil { + t.Fatal("expected error for 403") + } +} + +func TestPagerDutyResolveConnector_ResolvesIncident(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "PUT" || r.URL.Path != "/incidents/Q2AVLPZB5RX" { + t.Errorf("unexpected %s %s", r.Method, r.URL.Path) + } + if r.Header.Get("From") != "oncall@example.com" { + t.Errorf("expected From header, got %s", r.Header.Get("From")) + } + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + inc := body["incident"].(map[string]any) + if inc["status"] != "resolved" { + t.Errorf("expected status=resolved, got %v", inc["status"]) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "incident": map[string]any{ + "id": "Q2AVLPZB5RX", + "status": "resolved", + }, + }) + })) + defer srv.Close() + + c := &PagerDutyResolveConnector{baseURL: srv.URL} + out, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{"token": "u+abc", "from_email": "oncall@example.com"}, + "incident_id": "Q2AVLPZB5RX", + }) + if err != nil { + t.Fatal(err) + } + if out["status"] != "resolved" { + t.Errorf("expected status=resolved, got %v", out["status"]) + } +} + +func TestPagerDutyResolveConnector_FromEmailInParam(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("From") != "param@example.com" { + t.Errorf("expected From=param@example.com, got %s", r.Header.Get("From")) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "incident": map[string]any{"id": "Q1", "status": "resolved"}, + }) + })) + defer srv.Close() + + c := &PagerDutyResolveConnector{baseURL: srv.URL} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{"token": "u+abc"}, + "incident_id": "Q1", + "from_email": "param@example.com", + }) + if err != nil { + t.Fatal(err) + } +} + +func TestPagerDutyResolveConnector_MissingFromEmail(t *testing.T) { + c := &PagerDutyResolveConnector{baseURL: "http://unused"} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{"token": "u+abc"}, + "incident_id": "Q1", + }) + if err == nil { + t.Fatal("expected error when from_email is missing") + } +} + +func TestPagerDutyResolveConnector_MissingIncidentID(t *testing.T) { + c := &PagerDutyResolveConnector{baseURL: "http://unused"} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{"token": "u+abc"}, + }) + if err == nil { + t.Fatal("expected error for missing incident_id") + } +} + +func TestPagerDutyResolveConnector_APIError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"error":{"message":"Not Found"}}`, http.StatusNotFound) + })) + defer srv.Close() + + c := &PagerDutyResolveConnector{baseURL: srv.URL} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{"token": "u+abc"}, + "incident_id": "QMISSING", + }) + if err == nil { + t.Fatal("expected error for 404") + } +} + +func TestPagerDutyCreateIncidentConnector_MapAnyCredential(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Token token=mapany" { + t.Errorf("unexpected auth: %s", r.Header.Get("Authorization")) + } + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]any{ + "incident": map[string]any{ + "id": "Q1", "incident_number": 1, + "title": "t", "status": "triggered", "urgency": "high", "html_url": "", + }, + }) + })) + defer srv.Close() + + c := &PagerDutyCreateIncidentConnector{baseURL: srv.URL} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]any{"token": "mapany"}, + "title": "t", + "service_id": "SVC1", + }) + if err != nil { + t.Fatal(err) + } +} + +func TestRegistry_PagerDutyConnectors(t *testing.T) { + r := NewRegistry() + if _, err := r.Get("pagerduty/create_incident"); err != nil { + t.Errorf("pagerduty/create_incident not registered: %v", err) + } + if _, err := r.Get("pagerduty/resolve"); err != nil { + t.Errorf("pagerduty/resolve not registered: %v", err) + } +} diff --git a/packages/engine/internal/connector/twilio.go b/packages/engine/internal/connector/twilio.go new file mode 100644 index 0000000..5e640dc --- /dev/null +++ b/packages/engine/internal/connector/twilio.go @@ -0,0 +1,207 @@ +package connector + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +const twilioBaseURL = "https://api.twilio.com/2010-04-01" + +// TwilioSMSConnector sends an SMS message via Twilio. +type TwilioSMSConnector struct { + Client *http.Client + baseURL string // override for testing +} + +func (c *TwilioSMSConnector) apiURL(accountSID, path string) string { + base := c.baseURL + if base == "" { + base = twilioBaseURL + } + return fmt.Sprintf("%s/Accounts/%s%s", base, url.PathEscape(accountSID), path) +} + +func (c *TwilioSMSConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) { + accountSID, authToken, err := extractTwilioCredential(params) + if err != nil { + return nil, fmt.Errorf("twilio/sms: %w", err) + } + + to, _ := params["to"].(string) + if to == "" { + return nil, fmt.Errorf("twilio/sms: to is required") + } + from, _ := params["from"].(string) + if from == "" { + return nil, fmt.Errorf("twilio/sms: from is required") + } + body, _ := params["body"].(string) + if body == "" { + return nil, fmt.Errorf("twilio/sms: body is required") + } + + form := url.Values{} + form.Set("To", to) + form.Set("From", from) + form.Set("Body", body) + + req, err := http.NewRequestWithContext(ctx, "POST", c.apiURL(accountSID, "/Messages.json"), strings.NewReader(form.Encode())) + if err != nil { + return nil, fmt.Errorf("twilio/sms: creating request: %w", err) + } + req.SetBasicAuth(accountSID, authToken) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := httpClient(c.Client).Do(req) + if err != nil { + return nil, fmt.Errorf("twilio/sms: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(io.LimitReader(resp.Body, DefaultMaxResponseBytes)) + if err != nil { + return nil, fmt.Errorf("twilio/sms: reading response: %w", err) + } + + if resp.StatusCode != http.StatusCreated { + return nil, fmt.Errorf("twilio/sms: Twilio API returned %d: %s", resp.StatusCode, truncate(string(respBody), 500)) + } + + var msg struct { + SID string `json:"sid"` + Status string `json:"status"` + To string `json:"to"` + From string `json:"from"` + Body string `json:"body"` + } + if err := json.Unmarshal(respBody, &msg); err != nil { + return nil, fmt.Errorf("twilio/sms: parsing response: %w", err) + } + + return map[string]any{ + "sid": msg.SID, + "status": msg.Status, + "to": msg.To, + "from": msg.From, + "body": msg.Body, + }, nil +} + +// TwilioCallConnector initiates an outbound phone call via Twilio. +type TwilioCallConnector struct { + Client *http.Client + baseURL string // override for testing +} + +func (c *TwilioCallConnector) apiURL(accountSID, path string) string { + base := c.baseURL + if base == "" { + base = twilioBaseURL + } + return fmt.Sprintf("%s/Accounts/%s%s", base, url.PathEscape(accountSID), path) +} + +func (c *TwilioCallConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) { + accountSID, authToken, err := extractTwilioCredential(params) + if err != nil { + return nil, fmt.Errorf("twilio/call: %w", err) + } + + to, _ := params["to"].(string) + if to == "" { + return nil, fmt.Errorf("twilio/call: to is required") + } + from, _ := params["from"].(string) + if from == "" { + return nil, fmt.Errorf("twilio/call: from is required") + } + + callURL, _ := params["url"].(string) + twiml, _ := params["twiml"].(string) + if callURL == "" && twiml == "" { + return nil, fmt.Errorf("twilio/call: url or twiml is required") + } + if callURL != "" && twiml != "" { + return nil, fmt.Errorf("twilio/call: url and twiml are mutually exclusive; provide only one") + } + + form := url.Values{} + form.Set("To", to) + form.Set("From", from) + if callURL != "" { + form.Set("Url", callURL) + } else { + form.Set("Twiml", twiml) + } + + req, err := http.NewRequestWithContext(ctx, "POST", c.apiURL(accountSID, "/Calls.json"), strings.NewReader(form.Encode())) + if err != nil { + return nil, fmt.Errorf("twilio/call: creating request: %w", err) + } + req.SetBasicAuth(accountSID, authToken) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := httpClient(c.Client).Do(req) + if err != nil { + return nil, fmt.Errorf("twilio/call: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(io.LimitReader(resp.Body, DefaultMaxResponseBytes)) + if err != nil { + return nil, fmt.Errorf("twilio/call: reading response: %w", err) + } + + if resp.StatusCode != http.StatusCreated { + return nil, fmt.Errorf("twilio/call: Twilio API returned %d: %s", resp.StatusCode, truncate(string(respBody), 500)) + } + + var call struct { + SID string `json:"sid"` + Status string `json:"status"` + To string `json:"to"` + From string `json:"from"` + } + if err := json.Unmarshal(respBody, &call); err != nil { + return nil, fmt.Errorf("twilio/call: parsing response: %w", err) + } + + return map[string]any{ + "sid": call.SID, + "status": call.Status, + "to": call.To, + "from": call.From, + }, nil +} + +// extractTwilioCredential extracts account_sid and auth_token from _credential. +func extractTwilioCredential(params map[string]any) (accountSID, authToken string, err error) { + raw, ok := params["_credential"] + if !ok || raw == nil { + return "", "", fmt.Errorf("credential is required") + } + delete(params, "_credential") + + switch cred := raw.(type) { + case map[string]string: + accountSID = cred["account_sid"] + authToken = cred["auth_token"] + case map[string]any: + accountSID, _ = cred["account_sid"].(string) + authToken, _ = cred["auth_token"].(string) + default: + return "", "", fmt.Errorf("credential is required") + } + if accountSID == "" { + return "", "", fmt.Errorf("credential must contain an 'account_sid' field") + } + if authToken == "" { + return "", "", fmt.Errorf("credential must contain an 'auth_token' field") + } + return accountSID, authToken, nil +} diff --git a/packages/engine/internal/connector/twilio_test.go b/packages/engine/internal/connector/twilio_test.go new file mode 100644 index 0000000..b02215f --- /dev/null +++ b/packages/engine/internal/connector/twilio_test.go @@ -0,0 +1,290 @@ +package connector + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +func TestTwilioSMSConnector_SendsSMS(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Errorf("expected POST, got %s", r.Method) + } + if !strings.HasSuffix(r.URL.Path, "/Messages.json") { + t.Errorf("unexpected path: %s", r.URL.Path) + } + if r.Header.Get("Content-Type") != "application/x-www-form-urlencoded" { + t.Errorf("unexpected content-type: %s", r.Header.Get("Content-Type")) + } + + // Verify basic auth. + user, pass, ok := r.BasicAuth() + if !ok || user != "ACtest123" || pass != "secret" { + t.Errorf("unexpected basic auth: %s/%s", user, pass) + } + + r.ParseForm() + if r.FormValue("To") != "+15551234567" { + t.Errorf("unexpected To: %s", r.FormValue("To")) + } + if r.FormValue("From") != "+15559876543" { + t.Errorf("unexpected From: %s", r.FormValue("From")) + } + if r.FormValue("Body") != "Hello!" { + t.Errorf("unexpected Body: %s", r.FormValue("Body")) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{ + "sid": "SM123", + "status": "queued", + "to": "+15551234567", + "from": "+15559876543", + "body": "Hello!" + }`)) + })) + defer srv.Close() + + c := &TwilioSMSConnector{baseURL: srv.URL} + out, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{"account_sid": "ACtest123", "auth_token": "secret"}, + "to": "+15551234567", + "from": "+15559876543", + "body": "Hello!", + }) + if err != nil { + t.Fatal(err) + } + if out["sid"] != "SM123" { + t.Errorf("expected sid=SM123, got %v", out["sid"]) + } + if out["status"] != "queued" { + t.Errorf("expected status=queued, got %v", out["status"]) + } +} + +func TestTwilioSMSConnector_MissingTo(t *testing.T) { + c := &TwilioSMSConnector{baseURL: "http://unused"} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{"account_sid": "ACtest123", "auth_token": "secret"}, + "from": "+15559876543", + "body": "Hello!", + }) + if err == nil { + t.Fatal("expected error for missing to") + } +} + +func TestTwilioSMSConnector_MissingFrom(t *testing.T) { + c := &TwilioSMSConnector{baseURL: "http://unused"} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{"account_sid": "ACtest123", "auth_token": "secret"}, + "to": "+15551234567", + "body": "Hello!", + }) + if err == nil { + t.Fatal("expected error for missing from") + } +} + +func TestTwilioSMSConnector_MissingBody(t *testing.T) { + c := &TwilioSMSConnector{baseURL: "http://unused"} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{"account_sid": "ACtest123", "auth_token": "secret"}, + "to": "+15551234567", + "from": "+15559876543", + }) + if err == nil { + t.Fatal("expected error for missing body") + } +} + +func TestTwilioSMSConnector_MissingCredential(t *testing.T) { + c := &TwilioSMSConnector{baseURL: "http://unused"} + _, err := c.Execute(t.Context(), map[string]any{ + "to": "+15551234567", + "from": "+15559876543", + "body": "Hello!", + }) + if err == nil { + t.Fatal("expected error for missing credential") + } +} + +func TestTwilioSMSConnector_APIError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"code":21211,"message":"The 'To' number is not a valid phone number"}`)) + })) + defer srv.Close() + + c := &TwilioSMSConnector{baseURL: srv.URL} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{"account_sid": "ACtest123", "auth_token": "secret"}, + "to": "invalid", + "from": "+15559876543", + "body": "Hello!", + }) + if err == nil { + t.Fatal("expected error for 400") + } +} + +func TestTwilioCallConnector_CallWithURL(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasSuffix(r.URL.Path, "/Calls.json") { + t.Errorf("unexpected path: %s", r.URL.Path) + } + r.ParseForm() + if r.FormValue("Url") != "https://handler.twilio.com/twiml/EX1234" { + t.Errorf("unexpected Url: %s", r.FormValue("Url")) + } + if r.FormValue("Twiml") != "" { + t.Errorf("Twiml should be empty when Url is set") + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{"sid":"CA123","status":"queued","to":"+15551234567","from":"+15559876543"}`)) + })) + defer srv.Close() + + c := &TwilioCallConnector{baseURL: srv.URL} + out, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{"account_sid": "ACtest123", "auth_token": "secret"}, + "to": "+15551234567", + "from": "+15559876543", + "url": "https://handler.twilio.com/twiml/EX1234", + }) + if err != nil { + t.Fatal(err) + } + if out["sid"] != "CA123" { + t.Errorf("expected sid=CA123, got %v", out["sid"]) + } +} + +func TestTwilioCallConnector_CallWithTwiml(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r.ParseForm() + twiml := r.FormValue("Twiml") + if !strings.Contains(twiml, "") { + t.Errorf("expected in Twiml: %s", twiml) + } + if r.FormValue("Url") != "" { + t.Errorf("Url should be empty when Twiml is set") + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{"sid":"CA456","status":"queued","to":"+15551234567","from":"+15559876543"}`)) + })) + defer srv.Close() + + c := &TwilioCallConnector{baseURL: srv.URL} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{"account_sid": "ACtest123", "auth_token": "secret"}, + "to": "+15551234567", + "from": "+15559876543", + "twiml": "Hello!", + }) + if err != nil { + t.Fatal(err) + } +} + +func TestTwilioCallConnector_MissingURLAndTwiml(t *testing.T) { + c := &TwilioCallConnector{baseURL: "http://unused"} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{"account_sid": "ACtest123", "auth_token": "secret"}, + "to": "+15551234567", + "from": "+15559876543", + }) + if err == nil { + t.Fatal("expected error when neither url nor twiml is set") + } +} + +func TestTwilioCallConnector_BothURLAndTwiml(t *testing.T) { + c := &TwilioCallConnector{baseURL: "http://unused"} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{"account_sid": "ACtest123", "auth_token": "secret"}, + "to": "+15551234567", + "from": "+15559876543", + "url": "https://example.com/twiml", + "twiml": "Hi", + }) + if err == nil { + t.Fatal("expected error when both url and twiml are set") + } +} + +func TestTwilioCallConnector_MissingTo(t *testing.T) { + c := &TwilioCallConnector{baseURL: "http://unused"} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{"account_sid": "ACtest123", "auth_token": "secret"}, + "from": "+15559876543", + "url": "https://example.com/twiml", + }) + if err == nil { + t.Fatal("expected error for missing to") + } +} + +func TestTwilioSMSConnector_AccountSIDInPath(t *testing.T) { + // Verify that the account SID is included in the URL path. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.URL.Path, url.PathEscape("ACtest123")) { + t.Errorf("account SID not found in path: %s", r.URL.Path) + } + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{"sid":"SM1","status":"queued","to":"+1","from":"+2","body":"hi"}`)) + })) + defer srv.Close() + + c := &TwilioSMSConnector{baseURL: srv.URL} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{"account_sid": "ACtest123", "auth_token": "secret"}, + "to": "+1", + "from": "+2", + "body": "hi", + }) + if err != nil { + t.Fatal(err) + } +} + +func TestTwilioSMSConnector_MapAnyCredential(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user, pass, ok := r.BasicAuth() + if !ok || user != "ACmap" || pass != "maptoken" { + t.Errorf("unexpected basic auth: %s/%s", user, pass) + } + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{"sid":"SM1","status":"queued","to":"+1","from":"+2","body":"hi"}`)) + })) + defer srv.Close() + + c := &TwilioSMSConnector{baseURL: srv.URL} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]any{"account_sid": "ACmap", "auth_token": "maptoken"}, + "to": "+1", + "from": "+2", + "body": "hi", + }) + if err != nil { + t.Fatal(err) + } +} + +func TestRegistry_TwilioConnectors(t *testing.T) { + r := NewRegistry() + if _, err := r.Get("twilio/sms"); err != nil { + t.Errorf("twilio/sms not registered: %v", err) + } + if _, err := r.Get("twilio/call"); err != nil { + t.Errorf("twilio/call not registered: %v", err) + } +}