Repository: amp-labs/connectors
Pull request #1557 (2025-04-10 13:34:47)

[CON-1414] feat: Added write functionality in the heyreach connector.
## Sample Write Response
![image](https://github.com/user-attachments/assets/99c047a8-fb98-45c9-b91d-f48e89ec0409)
![image](https://github.com/user-attachments/assets/d865322b-9c38-4399-a997-bb2717354d4a)
![image](https://github.com/user-attachments/assets/f962b58f-312a-46cc-b6fa-2402cd74db7e)

## Test Cases Result
![image](https://github.com/user-attachments/assets/371d1f0a-b1cd-4c5a-a36c-7c7d1c7949da)



Patch:
diff --git a/providers/heyreach/README.md b/providers/heyreach/README.md
index 21f5ff34..736c3b13 100644
--- a/providers/heyreach/README.md
+++ b/providers/heyreach/README.md
@@ -5,12 +5,20 @@
 Below is an exhaustive list of objects & methods supported on the objects
 
 Heyreach API environment : public
-
-| Object          | Resource   | Method
-| ----------------| -----------| -----|
-| Campaigns       | campaign   | read |
-| LinkedInAccount | li_account | read |
-| List            | list       | read |
+---------------------------------------------------------------------------------
+| Module          | object               | Resource                     | Method|
+| ----------------| ---------------------| -----------------------------| ------|
+| Campaigns       | Get All              | campaign/GetAll              | read  |
+|                 | AddLeadsToCampaignV2 | campaign/AddLeadsToCampaignV2| write |
+|-------------------------------------------------------------------------------|
+| LinkedInAccount | Get All              | li_account/GetAll            | read  |
+|-------------------------------------------------------------------------------|
+| List            | Get All              | list/GetAll                  | read  |
+|                 | AddLeadsToListV2     | li_account/AddLeadsToListV2  | write |
+|                 | CreateEmptyList      | list/CreateEmptyList         | write |
+|-------------------------------------------------------------------------------|
+| Inbox           | SendMessage          | inbox/SendMessage            | write |
+---------------------------------------------------------------------------------
 
 Heyreach connector offers API for:
   - PublicAuthentication
@@ -52,7 +60,7 @@ Heyreach connector offers API for:
       - GetMyNetworkForSender
 
 # Getting Metadata and Read
-Supported objects for metadata are PublicCampaigns, PublicLinkedAccount, and PublicList. The remaining objects do not have a GetAll endpoint. 
+Supported objects for metadata are PublicCampaigns, PublicLinkedAccount and PublicList. The remaining objects do not have a GetAll endpoint. 
 
 Reason for unsupported object:
 1. PublicInbox - This endpoint requires campaignIds and linkedInAccountIds in body.
@@ -60,3 +68,8 @@ Reason for unsupported object:
 3. PublicMyNetwork - This endpoint requires senderID in body.
 
 Read functionality uses Post method instead of Get method.
+
+# Write Functions
+Supported objects for write are AddLeadsToCampaignV2, AddLeadsToListV2, CreateEmptyList and SendMessage. The remaining objects do have a Post API.
+
+There is no API for updating the objects.
diff --git a/providers/heyreach/connector.go b/providers/heyreach/connector.go
index 7955697c..1b260350 100644
--- a/providers/heyreach/connector.go
+++ b/providers/heyreach/connector.go
@@ -8,6 +8,7 @@ import (
 	"github.com/amp-labs/connectors/internal/components/operations"
 	"github.com/amp-labs/connectors/internal/components/reader"
 	"github.com/amp-labs/connectors/internal/components/schema"
+	"github.com/amp-labs/connectors/internal/components/writer"
 	"github.com/amp-labs/connectors/providers"
 	"github.com/amp-labs/connectors/providers/heyreach/metadata"
 )
@@ -22,6 +23,7 @@ type Connector struct {
 	// Supported operations
 	components.SchemaProvider
 	components.Reader
+	components.Writer
 }
 
 func NewConnector(params common.Parameters) (*Connector, error) {
@@ -29,7 +31,6 @@ func NewConnector(params common.Parameters) (*Connector, error) {
 	return components.Initialize(providers.HeyReach, params, constructor)
 }
 
-// nolint:funlen
 func constructor(base *components.Connector) (*Connector, error) {
 	connector := &Connector{Connector: base}
 
@@ -52,5 +53,16 @@ func constructor(base *components.Connector) (*Connector, error) {
 		},
 	)
 
+	connector.Writer = writer.NewHTTPWriter(
+		connector.HTTPClient().Client,
+		registry,
+		connector.ProviderContext.Module(),
+		operations.WriteHandlers{
+			BuildRequest:  connector.buildWriteRequest,
+			ParseResponse: connector.parseWriteResponse,
+			ErrorHandler:  common.InterpretError,
+		},
+	)
+
 	return connector, nil
 }
diff --git a/providers/heyreach/handlers.go b/providers/heyreach/handlers.go
index 420da526..2583ce1d 100644
--- a/providers/heyreach/handlers.go
+++ b/providers/heyreach/handlers.go
@@ -9,6 +9,7 @@ import (
 
 	"github.com/amp-labs/connectors/common"
 	"github.com/amp-labs/connectors/common/urlbuilder"
+	"github.com/amp-labs/connectors/internal/jsonquery"
 )
 
 func (c *Connector) buildReadRequest(ctx context.Context, params common.ReadParams) (*http.Request, error) {
@@ -77,3 +78,53 @@ func constructRequestBody(config common.ReadParams) ([]byte, error) {
 
 	return jsonData, nil
 }
+
+func (c *Connector) buildWriteRequest(ctx context.Context, params common.WriteParams) (*http.Request, error) {
+	var (
+		url *urlbuilder.URL
+		err error
+	)
+
+	url, err = urlbuilder.New(c.ProviderInfo().BaseURL, "public", params.ObjectName)
+	if err != nil {
+		return nil, err
+	}
+
+	jsonData, err := json.Marshal(params.RecordData)
+	if err != nil {
+		return nil, err
+	}
+
+	return http.NewRequestWithContext(ctx, http.MethodPost, url.String(), bytes.NewReader(jsonData))
+}
+
+func (c *Connector) parseWriteResponse(
+	ctx context.Context,
+	params common.WriteParams,
+	request *http.Request,
+	response *common.JSONHTTPResponse,
+) (*common.WriteResult, error) {
+	body, ok := response.Body()
+	if !ok {
+		return &common.WriteResult{ // nolint:nilerr
+			Success: true,
+		}, nil
+	}
+
+	recordID, err := jsonquery.New(body).IntegerWithDefault("id", 0)
+	if err != nil {
+		return nil, err
+	}
+
+	resp, err := jsonquery.Convertor.ObjectToMap(body)
+	if err != nil {
+		return nil, err
+	}
+
+	return &common.WriteResult{
+		Success:  true,
+		RecordId: strconv.Itoa(int(recordID)),
+		Errors:   nil,
+		Data:     resp,
+	}, nil
+}
diff --git a/providers/heyreach/metadata/schemas.json b/providers/heyreach/metadata/schemas.json
index c9e0c7e5..5c4a7000 100644
--- a/providers/heyreach/metadata/schemas.json
+++ b/providers/heyreach/metadata/schemas.json
@@ -6,7 +6,7 @@
       "objects": {
         "campaign/GetAll": {
           "displayName": "GetAll campaign",
-          "path": "",
+          "path": "campaign/GetAll",
           "responseKey": "",
           "fields": {
             "id": {
@@ -21,7 +21,7 @@
               "displayName": "creationTime",
               "valueType": "other"
             },
-            "linkedInUserListName":{
+            "linkedInUserListName": {
               "displayName": "linkedInUserListName",
               "valueType": "other"
             },
diff --git a/providers/heyreach/supports.go b/providers/heyreach/supports.go
index e98308d5..aec41ede 100644
--- a/providers/heyreach/supports.go
+++ b/providers/heyreach/supports.go
@@ -12,12 +12,23 @@ import (
 func supportedOperations() components.EndpointRegistryInput {
 	readSupport := metadata.Schemas.ObjectNames().GetList(common.ModuleRoot)
 
+	supportWrite := []string{
+		"list/CreateEmptyList",
+		"campaign/AddLeadsToCampaignV2",
+		"list/AddLeadsToListV2",
+		"inbox/SendMessage",
+	}
+
 	return components.EndpointRegistryInput{
 		common.ModuleRoot: {
 			{
 				Endpoint: fmt.Sprintf("{%s}", strings.Join(readSupport, ",")),
 				Support:  components.ReadSupport,
 			},
+			{
+				Endpoint: fmt.Sprintf("{%s}", strings.Join(supportWrite, ",")),
+				Support:  components.WriteSupport,
+			},
 		},
 	}
 }
diff --git a/providers/heyreach/utils.go b/providers/heyreach/utils.go
index cbdd54fb..25fb49e5 100644
--- a/providers/heyreach/utils.go
+++ b/providers/heyreach/utils.go
@@ -12,7 +12,7 @@ const (
 	DefaultPageSize = 100
 )
 
-// To determine the next page records for the objects.
+// makeNextRecord creates a function that determines the next page token based on the current offset.
 func makeNextRecord(offset int) common.NextPageFunc {
 	return func(node *ajson.Node) (string, error) {
 		// Extract the data key value from the response.


Test patch:
diff --git a/providers/heyreach/metadata_test.go b/providers/heyreach/metadata_test.go
index 0d7b15c4..da6d4a9b 100644
--- a/providers/heyreach/metadata_test.go
+++ b/providers/heyreach/metadata_test.go
@@ -27,7 +27,7 @@ func TestListObjectMetadata(t *testing.T) { // nolint:funlen,gocognit,cyclop,mai
 			ExpectedErrs: []error{common.ErrMissingObjects},
 		},
 		{
-			Name:  "Successfully describe multiple object with metadata",
+			Name:  "Successfully describe multiple objects with metadata",
 			Input: []string{"campaign/GetAll", "list/GetAll", "li_account/GetAll"},
 			Server: mockserver.Switch{
 				Setup: mockserver.ContentJSON(),
@@ -170,7 +170,6 @@ func TestListObjectMetadata(t *testing.T) { // nolint:funlen,gocognit,cyclop,mai
 	}
 
 	for _, tt := range tests {
-		// nolint:varnamelen
 		t.Run(tt.Name, func(t *testing.T) {
 			t.Parallel()
 
diff --git a/providers/heyreach/read_test.go b/providers/heyreach/read_test.go
index ba56f9f4..1d744bc3 100644
--- a/providers/heyreach/read_test.go
+++ b/providers/heyreach/read_test.go
@@ -63,7 +63,7 @@ func TestRead(t *testing.T) { // nolint:funlen,gocognit,cyclop
 						Fields: map[string]any{},
 						Raw: map[string]any{
 							"id":           float64(71110),
-							"emailAddress": "sample.gmail.com",
+							"emailAddress": "sample@gmail.com",
 							"firstName":    nil,
 							"lastName":     nil,
 						},
diff --git a/providers/heyreach/test/add_lead_list_campaign.json b/providers/heyreach/test/add_lead_list_campaign.json
new file mode 100644
index 00000000..f647a27c
--- /dev/null
+++ b/providers/heyreach/test/add_lead_list_campaign.json
@@ -0,0 +1,5 @@
+{
+    "addedLeadsCount":   1,
+	"updatedLeadsCount": 0,
+	"failedLeadsCount":  0
+}
diff --git a/providers/heyreach/test/create_list.json b/providers/heyreach/test/create_list.json
new file mode 100644
index 00000000..5189d999
--- /dev/null
+++ b/providers/heyreach/test/create_list.json
@@ -0,0 +1,11 @@
+{
+    "id": 123,
+    "name": "My List",
+    "count": 0,
+    "listType": "COMPANY_LIST",
+    "creationTime": "2024-08-29T09:34:56.5417789Z",
+    "isDeleted": false,
+    "campaigns": null,
+    "search": null,
+    "status": "UNKNOWN"
+}
diff --git a/providers/heyreach/test/li_account.json b/providers/heyreach/test/li_account.json
index 7da95d46..b5c85a01 100644
--- a/providers/heyreach/test/li_account.json
+++ b/providers/heyreach/test/li_account.json
@@ -3,7 +3,7 @@
     "items": [
         {
             "id": 71110,
-            "emailAddress": "sample.gmail.com",
+            "emailAddress": "sample@gmail.com",
             "firstName": null,
             "lastName": null
         }
diff --git a/providers/heyreach/write_test.go b/providers/heyreach/write_test.go
new file mode 100644
index 00000000..8d1d44de
--- /dev/null
+++ b/providers/heyreach/write_test.go
@@ -0,0 +1,117 @@
+package heyreach
+
+import (
+	"net/http"
+	"testing"
+
+	"github.com/amp-labs/connectors"
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/test/utils/mockutils/mockcond"
+	"github.com/amp-labs/connectors/test/utils/mockutils/mockserver"
+	"github.com/amp-labs/connectors/test/utils/testroutines"
+	"github.com/amp-labs/connectors/test/utils/testutils"
+)
+
+func TestWrite(t *testing.T) { // nolint:funlen,gocognit,cyclop,maintidx
+	t.Parallel()
+
+	listResponse := testutils.DataFromFile(t, "create_list.json")
+	addLeadToCampaignAndListResponse := testutils.DataFromFile(t, "add_lead_list_campaign.json")
+
+	tests := []testroutines.Write{
+		{
+			Name:         "Write object must be included",
+			Server:       mockserver.Dummy(),
+			ExpectedErrs: []error{common.ErrMissingObjects},
+		},
+		{
+			Name:  "creating the list",
+			Input: common.WriteParams{ObjectName: "list/CreateEmptyList", RecordData: "dummy"},
+			Server: mockserver.Conditional{
+				Setup: mockserver.ContentJSON(),
+				If:    mockcond.MethodPOST(),
+				Then:  mockserver.Response(http.StatusOK, listResponse),
+			}.Server(),
+			Expected: &common.WriteResult{
+				Success:  true,
+				RecordId: "123",
+				Errors:   nil,
+				Data: map[string]any{
+					"id":           float64(123),
+					"name":         "My List",
+					"count":        float64(0),
+					"listType":     "COMPANY_LIST",
+					"creationTime": "2024-08-29T09:34:56.5417789Z",
+					"isDeleted":    false,
+					"campaigns":    nil,
+					"search":       nil,
+					"status":       "UNKNOWN",
+				},
+			},
+			ExpectedErrs: nil,
+		},
+		{
+			Name:  "Add Leads to campaign",
+			Input: common.WriteParams{ObjectName: "campaign/AddLeadsToCampaignV2", RecordData: "dummy"},
+			Server: mockserver.Conditional{
+				Setup: mockserver.ContentJSON(),
+				If:    mockcond.MethodPOST(),
+				Then:  mockserver.Response(http.StatusOK, addLeadToCampaignAndListResponse),
+			}.Server(),
+			Expected: &common.WriteResult{
+				Success:  true,
+				RecordId: "0",
+				Errors:   nil,
+				Data: map[string]any{
+					"addedLeadsCount":   float64(1),
+					"updatedLeadsCount": float64(0),
+					"failedLeadsCount":  float64(0),
+				},
+			},
+			ExpectedErrs: nil,
+		},
+		{
+			Name:  "Add Leads to list",
+			Input: common.WriteParams{ObjectName: "list/AddLeadsToListV2", RecordData: "dummy"},
+			Server: mockserver.Conditional{
+				Setup: mockserver.ContentJSON(),
+				If:    mockcond.MethodPOST(),
+				Then:  mockserver.Response(http.StatusOK, addLeadToCampaignAndListResponse),
+			}.Server(),
+			Expected: &common.WriteResult{
+				Success:  true,
+				RecordId: "0",
+				Errors:   nil,
+				Data: map[string]any{
+					"addedLeadsCount":   float64(1),
+					"updatedLeadsCount": float64(0),
+					"failedLeadsCount":  float64(0),
+				},
+			},
+			ExpectedErrs: nil,
+		},
+		{
+			Name:  "Sending message to LinkedIn conversation",
+			Input: common.WriteParams{ObjectName: "inbox/SendMessage", RecordData: "dummy"},
+			Server: mockserver.Conditional{
+				Setup: mockserver.ContentJSON(),
+				If:    mockcond.MethodPOST(),
+				Then:  mockserver.Response(http.StatusOK, nil),
+			}.Server(),
+			Expected: &common.WriteResult{
+				Success: true,
+			},
+			ExpectedErrs: nil,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.Name, func(t *testing.T) {
+			t.Parallel()
+
+			tt.Run(t, func() (connectors.WriteConnector, error) {
+				return constructTestConnector(tt.Server.URL)
+			})
+		})
+	}
+}
diff --git a/test/heyreach/read/main.go b/test/heyreach/read/main.go
index 6e320140..0c8a00dc 100644
--- a/test/heyreach/read/main.go
+++ b/test/heyreach/read/main.go
@@ -91,7 +91,7 @@ func testReadList(ctx context.Context, conn *ap.Connector) error {
 
 	res, err := conn.Read(ctx, params)
 	if err != nil {
-		return fmt.Errorf("failed to read li_account: %w", err)
+		return fmt.Errorf("failed to read list: %w", err)
 	}
 
 	// Print the results.
diff --git a/test/heyreach/write/main.go b/test/heyreach/write/main.go
new file mode 100644
index 00000000..d2716b81
--- /dev/null
+++ b/test/heyreach/write/main.go
@@ -0,0 +1,193 @@
+package main
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"log/slog"
+	"os"
+
+	"github.com/amp-labs/connectors/common"
+	ap "github.com/amp-labs/connectors/providers/heyreach"
+	"github.com/amp-labs/connectors/test/heyreach"
+)
+
+func main() {
+	os.Exit(MainFn())
+}
+
+func MainFn() int {
+	ctx := context.Background()
+
+	err := testList(ctx)
+	if err != nil {
+		return 1
+	}
+
+	err = testAddLeadToCampaign(ctx)
+	if err != nil {
+		return 1
+	}
+
+	err = testAddLeadToList(ctx)
+	if err != nil {
+		return 1
+	}
+
+	err = testSendMessage(ctx)
+	if err != nil {
+		return 1
+	}
+
+	return 0
+}
+
+func testList(ctx context.Context) error {
+	conn := heyreach.GetHeyreachConnector(ctx)
+
+	slog.Info("Creating the empty list")
+
+	writeParams := common.WriteParams{
+		ObjectName: "list/CreateEmptyList",
+		RecordData: map[string]any{
+			"name": "My List",
+			"type": "USER_LIST",
+		},
+		RecordId: "",
+	}
+
+	writeRes, err := Write(ctx, conn, writeParams)
+	if err != nil {
+		fmt.Println("ERR: ", err)
+
+		return err
+	}
+
+	if err := constructResponse(writeRes); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func testAddLeadToCampaign(ctx context.Context) error {
+	conn := heyreach.GetHeyreachConnector(ctx)
+
+	slog.Info("Add lead to existing campaign")
+
+	writeParams := common.WriteParams{
+		ObjectName: "campaign/AddLeadsToCampaignV2",
+		RecordData: map[string]any{
+			"campaignId": 120469,
+			"accountLeadPairs": []any{
+				map[string]any{
+					"lead": map[string]any{
+						"firstName":  "Dinesh",
+						"lastName":   "Kumar",
+						"profileUrl": "https://www.linkedid.com",
+					},
+				},
+			},
+		},
+		RecordId: "",
+	}
+
+	writeRes, err := Write(ctx, conn, writeParams)
+	if err != nil {
+		fmt.Println("ERR: ", err)
+
+		return err
+	}
+
+	if err := constructResponse(writeRes); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func testAddLeadToList(ctx context.Context) error {
+	conn := heyreach.GetHeyreachConnector(ctx)
+
+	slog.Info("Add lead to existing list")
+
+	writeParams := common.WriteParams{
+		ObjectName: "list/AddLeadsToListV2",
+		RecordData: map[string]any{
+			"listId": 196432,
+			"leads": []any{
+				map[string]any{
+					"firstName":  "Dinesh",
+					"lastName":   "Kumar",
+					"profileUrl": "https://www.linkedin.com/",
+				},
+			},
+		},
+		RecordId: "",
+	}
+
+	writeRes, err := Write(ctx, conn, writeParams)
+	if err != nil {
+		fmt.Println("ERR: ", err)
+
+		return err
+	}
+
+	if err := constructResponse(writeRes); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func testSendMessage(ctx context.Context) error {
+	conn := heyreach.GetHeyreachConnector(ctx)
+
+	slog.Info("Sending message to LinkedIn conversation")
+
+	writeParams := common.WriteParams{
+		ObjectName: "inbox/SendMessage",
+		RecordData: map[string]any{
+			"message":           "Hi",
+			"subject":           "Info",
+			"conversationId":    "2-YTQ4NjFkZWUtZTY1NS00ZWZhLTk0YTctYWVkMGNjYjFlYTBkXzEwMA==",
+			"linkedInAccountId": 72866,
+		},
+		RecordId: "",
+	}
+
+	writeRes, err := Write(ctx, conn, writeParams)
+	if err != nil {
+		fmt.Println("ERR: ", err)
+
+		return err
+	}
+
+	if err := constructResponse(writeRes); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func Write(ctx context.Context, conn *ap.Connector, payload common.WriteParams) (*common.WriteResult, error) {
+	res, err := conn.Write(ctx, payload)
+	if err != nil {
+		return nil, err
+	}
+
+	return res, nil
+}
+
+// unmarshal the write response.
+func constructResponse(res *common.WriteResult) error {
+	jsonStr, err := json.MarshalIndent(res, "", " ")
+	if err != nil {
+		return fmt.Errorf("error marshalling JSON: %w", err)
+	}
+
+	_, _ = os.Stdout.Write(jsonStr)
+	_, _ = os.Stdout.WriteString("\n")
+
+	return nil
+}